Politician 1.0.0
WiFi Auditing Library for ESP32
Loading...
Searching...
No Matches
PoliticianSense.h
Go to the documentation of this file.
1#pragma once
2/**
3 * @file PoliticianSense.h
4 * @brief Opt-in passive RSSI-based presence and motion sensing for the Politician engine.
5 *
6 * Hooks into the engine's promiscuous-mode packet stream and monitors beacon RSSI
7 * variance from a fixed anchor AP to detect human presence and motion — device-free,
8 * zero mode switching, zero conflicts with the core engine.
9 *
10 * A human body walking between the anchor AP and the ESP32 scatters and absorbs
11 * 2.4GHz/5GHz radio waves, producing measurable fluctuations in received signal
12 * strength. By tracking the statistical variance of beacon RSSI over a sliding
13 * window, PoliticianSense classifies the monitored space as SENSE_STILL or SENSE_MOTION.
14 *
15 * Usage:
16 * @code
17 * #include <Politician.h>
18 * #include <PoliticianSense.h>
19 *
20 * politician::Politician engine;
21 * politician::PoliticianSense sense;
22 *
23 * // Lock the engine to the anchor AP's channel for continuous data.
24 * // During free channel hopping, samples only arrive when the radio
25 * // happens to land on the anchor's channel (coarser, but still works).
26 * uint8_t anchorBssid[] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF};
27 *
28 * // LOG_FILTER_BEACONS must be set before engine.begin().
29 * politician::Config cfg;
30 * cfg.capture_filter |= politician::LOG_FILTER_BEACONS;
31 * engine.begin(cfg);
32 * engine.lockChannel(6); // anchor AP's channel
33 *
34 * sense.begin(engine, anchorBssid);
35 * sense.setSenseCallback([](politician::SenseEvent ev, float variance) {
36 * if (ev == politician::SENSE_MOTION)
37 * Serial.printf("[SENSE] Motion detected! variance=%.2f dBm²\n", variance);
38 * else
39 * Serial.printf("[SENSE] Area is still. variance=%.2f dBm²\n", variance);
40 * });
41 *
42 * // In loop():
43 * engine.tick();
44 * sense.tick();
45 * @endcode
46 *
47 * Anchor modes:
48 * - Pass a 6-byte BSSID to anchor to a specific AP (recommended — most stable).
49 * - Call beginBySSID() to look up the BSSID by SSID from the engine cache.
50 * - Pass nullptr to sample all visible APs (noisier, useful without a fixed AP).
51 *
52 * Threading:
53 * RSSI samples are written from Politician's internal worker task.
54 * tick() and all sense callbacks run on your calling task (usually loop()).
55 * A FreeRTOS spinlock protects the ring buffer across tasks.
56 *
57 * Packet logger chaining:
58 * PoliticianSense installs itself as the engine's packet logger.
59 * If you also need raw frame access, call sense.setPacketLogger() before begin()
60 * to register a pass-through callback that fires on every frame after sensing.
61 *
62 * Compile-time tuning:
63 * #define POLITICIAN_SENSE_MAX_WINDOW 128 // Enlarge maximum window (default 64)
64 */
65
66#include "Politician.h"
67#include <freertos/FreeRTOS.h>
68#include <freertos/portmacro.h>
69#include <string.h>
70
71#ifdef POLITICIAN_NO_STD_FUNCTION
72#error "PoliticianSense.h requires std::function (POLITICIAN_NO_STD_FUNCTION must not be defined)."
73#endif
74
75namespace politician {
76
77// ─── Sense Events ─────────────────────────────────────────────────────────────
78
79/** @brief State transition delivered to the SenseCb callback. */
80enum SenseEvent : uint8_t {
81 SENSE_MOTION = 0, ///< RSSI variance spiked above threshold — movement detected.
82 SENSE_STILL = 1, ///< RSSI variance returned to baseline — area quiet.
83};
84
85// ─── Callback Type ────────────────────────────────────────────────────────────
86
87/** @brief Callback fired on SENSE_STILL ↔ SENSE_MOTION transitions. */
88using SenseCb = std::function<void(SenseEvent event, float variance)>;
89
90// ─── Compile-Time Ceiling ─────────────────────────────────────────────────────
91
92#ifndef POLITICIAN_SENSE_MAX_WINDOW
93#define POLITICIAN_SENSE_MAX_WINDOW 64 ///< Maximum ring-buffer capacity (samples).
94#endif
95
96// ─── PoliticianSense ──────────────────────────────────────────────────────────
97
98/**
99 * @brief Passive RSSI-based motion and presence detector.
100 *
101 * Hooks into a Politician engine via the packet logger callback and tracks
102 * RSSI variance from a fixed anchor AP over a configurable sliding window.
103 * Fires a SenseCb whenever the space transitions between quiet and active.
104 */
106public:
107 // ── Defaults ──────────────────────────────────────────────────────────────
108 static constexpr uint8_t DEFAULT_WINDOW = 32; ///< Sliding window (samples).
109 static constexpr float DEFAULT_THRESHOLD = 6.0f; ///< Variance threshold (dBm²).
110 static constexpr uint32_t DEFAULT_DEBOUNCE = 2000; ///< Motion hold-time (ms).
111 static constexpr uint32_t DEFAULT_STALE_MS = 10000; ///< Max gap before ignoring stale data (ms).
112
114 : _engine(nullptr)
115 , _anyAnchor(true)
116 , _active(false)
117 , _head(0), _count(0), _windowSize(DEFAULT_WINDOW)
118 , _threshold(DEFAULT_THRESHOLD)
119 , _debounceMs(DEFAULT_DEBOUNCE)
120 , _staleMs(DEFAULT_STALE_MS)
121 , _mean(0.0f), _variance(0.0f)
122 , _state(SENSE_STILL)
123 , _lastMotionMs(0), _lastSampleMs(0)
124 , _totalSamples(0)
125 {
126 memset(_anchor, 0, 6);
127 memset(_buf, 0, sizeof(_buf));
128 _mux = portMUX_INITIALIZER_UNLOCKED;
129 }
130
131 // ── Setup ─────────────────────────────────────────────────────────────────
132
133 /**
134 * @brief Attaches the sensor to a Politician engine.
135 *
136 * @param engine The running Politician instance.
137 * @param anchorBssid 6-byte BSSID of the anchor AP, or nullptr to sample every AP.
138 *
139 * Installs an internal packet logger on the engine. If you also need raw
140 * frame access, call sense.setPacketLogger() **before** begin() to chain a
141 * pass-through callback. Setting it after begin() is a data race — the
142 * engine worker task may be concurrently reading _userPacketCb.
143 *
144 * The engine must already be initialized (begin() called) before calling this.
145 * For continuous sensing, lock the engine to the anchor's channel:
146 * engine.lockChannel(anchorChannel);
147 *
148 * @note `cfg.capture_filter` must include `LOG_FILTER_BEACONS` before
149 * `engine.begin()` is called. Beacons are the primary RSSI source and
150 * PoliticianSense will collect no samples without them. Set it in Config:
151 * @code
152 * Config cfg;
153 * cfg.capture_filter |= LOG_FILTER_BEACONS;
154 * engine.begin(cfg);
155 * sense.begin(engine, anchorBssid);
156 * @endcode
157 */
158 void begin(Politician &engine, const uint8_t *anchorBssid = nullptr) {
159 // Detach from a previous engine if re-anchoring to a different one,
160 // so the old engine does not keep a lambda that captures this object.
161 if (_engine && _engine != &engine) {
162 _engine->setPacketLogger(nullptr);
163 }
164 _engine = &engine;
165 _reset_internal();
166
167 // Update anchor under the same lock used by _onPacket, so the worker task
168 // never observes a partially-written anchor during reconfiguration.
169 portENTER_CRITICAL_SAFE(&_mux);
170 if (anchorBssid && memcmp(anchorBssid, "\x00\x00\x00\x00\x00\x00", 6) != 0) {
171 memcpy(_anchor, anchorBssid, 6);
172 _anyAnchor = false;
173 } else {
174 memset(_anchor, 0, 6);
175 _anyAnchor = true;
176 }
177 portEXIT_CRITICAL_SAFE(&_mux);
178
179 _engine->setPacketLogger([this](const uint8_t *payload, uint16_t len,
180 int8_t rssi, uint8_t ch, uint32_t ts) {
181 _onPacket(payload, len, rssi, ch, ts);
182 });
183 _active = true;
184 }
185
186 /**
187 * @brief Looks up an AP by SSID in the engine cache and anchors to its BSSID.
188 *
189 * @param engine The running Politician instance.
190 * @param ssid Exact SSID string to match.
191 * @return true if the SSID was found and the sensor was anchored.
192 * false if the SSID is not yet in the engine's AP cache — call again
193 * after the engine has had time to scan.
194 *
195 * When multiple BSSIDs share the same SSID the strongest signal is chosen.
196 */
197 bool beginBySSID(Politician &engine, const char *ssid) {
198 if (!ssid) return false;
199 int best = -999;
200 uint8_t bestBssid[6] = {};
201 bool found = false;
202
203 engine.forEachAp([&](const ApRecord &ap) {
204 if (strncmp(ap.ssid, ssid, sizeof(ap.ssid)) == 0) {
205 if (ap.rssi > best) {
206 best = ap.rssi;
207 memcpy(bestBssid, ap.bssid, 6);
208 found = true;
209 }
210 }
211 });
212
213 if (found) begin(engine, bestBssid);
214 return found;
215 }
216
217 // ── Callbacks ─────────────────────────────────────────────────────────────
218
219 /**
220 * @brief Sets the state-change callback.
221 * Fired once when the space transitions STILL→MOTION or MOTION→STILL.
222 * Do not call engine.tick() or blocking operations from inside the callback.
223 */
224 void setSenseCallback(SenseCb cb) { _senseCb = cb; }
225
226 /**
227 * @brief Registers a pass-through raw-packet callback.
228 * Called on every frame after PoliticianSense has processed it,
229 * so you can use raw packet access alongside sensing.
230 *
231 * @note Like all Politician callbacks, this must be set before begin() or
232 * after end(). Changing it while the engine is running is not thread-safe.
233 */
234 void setPacketLogger(Politician::PacketCb cb) { _userPacketCb = cb; }
235
236 // ── Tuning ────────────────────────────────────────────────────────────────
237
238 /**
239 * @brief Sets the variance threshold (dBm²) that triggers SENSE_MOTION.
240 * Lower values = more sensitive; higher values = less false positives.
241 * Useful range: 3.0–15.0. Default: 6.0
242 */
243 void setThreshold(float dBm2) { _threshold = dBm2; }
244
245 /**
246 * @brief Sets the sliding window size in samples (clamped to [4, POLITICIAN_SENSE_MAX_WINDOW]).
247 * At ~10 beacons/sec on a locked channel, 32 samples ≈ 3 seconds of history.
248 * Smaller = faster response; larger = smoother, fewer false triggers. Default: 32
249 */
250 void setWindowSize(uint8_t n) {
251 if (n < 4) n = 4;
253 portENTER_CRITICAL_SAFE(&_mux);
254 _windowSize = n;
255 _head = 0;
256 _count = 0;
257 portEXIT_CRITICAL_SAFE(&_mux);
258 }
259
260 /**
261 * @brief Sets the debounce hold-time (ms).
262 * MOTION state is held for this long after the last variance spike before
263 * returning to STILL, preventing rapid flickering during intermittent movement.
264 * Default: 2000 ms
265 */
266 void setDebounce(uint32_t ms) { _debounceMs = ms; }
267
268 /**
269 * @brief Sets the stale-data timeout (ms).
270 * If no new RSSI samples arrive for this duration, tick() skips processing
271 * to avoid acting on stale window data (e.g., when hopping away from the anchor).
272 * Default: 10000 ms
273 */
274 void setStaleTimeout(uint32_t ms) { _staleMs = ms; }
275
276 // ── Worker ────────────────────────────────────────────────────────────────
277
278 /**
279 * @brief Main worker — call from loop() alongside engine.tick().
280 * Computes variance from the current window snapshot and fires the
281 * SenseCb if a STILL↔MOTION transition is detected.
282 */
283 void tick() {
284 if (!_engine) return;
285
286 // Snapshot the ring buffer under the lock
287 int8_t snap[POLITICIAN_SENSE_MAX_WINDOW];
288 uint8_t snapCount, snapStart, snapWindow;
289 uint32_t lastSample;
290
291 portENTER_CRITICAL_SAFE(&_mux);
292 snapCount = _count;
293 snapWindow = _windowSize;
294 snapStart = (snapCount < snapWindow) ? 0 : _head;
295 lastSample = _lastSampleMs;
296 memcpy(snap, _buf, snapWindow);
297 portEXIT_CRITICAL_SAFE(&_mux);
298
299 // Need at least 4 samples before starting the state machine
300 if (snapCount < 4) return;
301
302 bool isStale = (_staleMs > 0 && (millis() - lastSample) > _staleMs);
303
304 if (isStale) {
305 // No fresh data: decay variance to zero so the debounce can still
306 // expire and transition MOTION → STILL. Do not freeze state forever.
307 _variance = 0.0f;
308 _mean = 0.0f;
309 } else {
310 // ── Compute mean ──
311 float sum = 0.0f;
312 for (uint8_t i = 0; i < snapCount; i++) {
313 sum += snap[(snapStart + i) % snapWindow];
314 }
315 _mean = sum / snapCount;
316
317 // ── Compute variance ──
318 float varSum = 0.0f;
319 for (uint8_t i = 0; i < snapCount; i++) {
320 float d = snap[(snapStart + i) % snapWindow] - _mean;
321 varSum += d * d;
322 }
323 _variance = varSum / snapCount;
324 }
325
326 // ── State machine ──
327 uint32_t now = millis();
328 SenseEvent prev = _state;
329
330 if (_variance >= _threshold) {
331 _lastMotionMs = now;
332 _state = SENSE_MOTION;
333 } else if (_state == SENSE_MOTION) {
334 if ((now - _lastMotionMs) >= _debounceMs) {
335 _state = SENSE_STILL;
336 }
337 }
338
339 if (_state != prev && _senseCb) {
340 _senseCb(_state, _variance);
341 }
342 }
343
344 // ── Accessors ─────────────────────────────────────────────────────────────
345
346 /** @return Current RSSI variance across the window (dBm²). Updated by tick(). */
347 float getVariance() const { return _variance; }
348
349 /** @return Mean RSSI across the window (dBm). Updated by tick(). */
350 float getMeanRssi() const { return _mean; }
351
352 /** @return Current sense state (SENSE_STILL or SENSE_MOTION). */
353 SenseEvent getState() const { return _state; }
354
355 /** @return Total RSSI samples collected since begin(). */
356 uint32_t getTotalSamples() const {
357 portENTER_CRITICAL_SAFE(&_mux);
358 uint32_t n = _totalSamples;
359 portEXIT_CRITICAL_SAFE(&_mux);
360 return n;
361 }
362
363 /** @return True if anchored to a specific BSSID, false if sampling all APs. */
364 bool hasAnchor() const { return !_anyAnchor; }
365
366 /** @return Pointer to the 6-byte anchor BSSID (all-zeros if any-anchor mode). */
367 const uint8_t *getAnchor() const { return _anchor; }
368
369 /**
370 * @brief Clears the sample window and resets state without detaching from the engine.
371 * Useful when the environment changes (furniture moved, AP relocated, etc.).
372 */
373 void reset() { _reset_internal(); }
374
375 /**
376 * @brief Detaches from the engine and clears its packet logger.
377 *
378 * Sets _active = false first so any _onPacket() invocation already in flight
379 * on the engine worker task will bail out immediately without touching members.
380 * Then clears the engine's packet logger slot so no further calls are dispatched.
381 *
382 * @warning This does NOT provide a hard synchronisation barrier. If the engine
383 * worker task has already passed the `if (!_active)` guard before end() writes
384 * the flag, it may still access members after end() returns. This window is
385 * narrow but real on a multi-core ESP32.
386 *
387 * Safe usage pattern when destroying from a different core/task:
388 * @code
389 * sense.end();
390 * delay(20); // > one engine tick — guarantees any in-flight call has returned
391 * // now safe to destroy or reuse
392 * @endcode
393 *
394 * Calling end() from the same task/core as the engine worker (e.g. in loop())
395 * is always safe with no delay required.
396 */
397 void end() {
398 if (_engine) {
399 // Clear the active flag first. Any _onPacket() call already dispatched
400 // by the worker task will see _active=false and return immediately
401 // without touching any members, narrowing the use-after-free window.
402 _active = false;
403 _engine->setPacketLogger(nullptr);
404 _engine = nullptr;
405 }
406 }
407
408 ~PoliticianSense() { end(); }
409
410private:
411 Politician *_engine;
412 uint8_t _anchor[6];
413 bool _anyAnchor;
414 volatile bool _active; ///< Set true by begin(), false by end(). Guards _onPacket against use-after-free.
415
416 // ── Ring buffer (written from Politician worker task) ─────────────────────
417 mutable portMUX_TYPE _mux;
418 int8_t _buf[POLITICIAN_SENSE_MAX_WINDOW];
419 uint8_t _head; ///< Next write position
420 uint8_t _count; ///< Valid samples in window (≤ _windowSize)
421 uint8_t _windowSize;
422
423 // ── Config ────────────────────────────────────────────────────────────────
424 float _threshold;
425 uint32_t _debounceMs;
426 uint32_t _staleMs;
427
428 // ── Computed by tick() ────────────────────────────────────────────────────
429 float _mean;
430 float _variance;
431 SenseEvent _state;
432 uint32_t _lastMotionMs;
433
434 // ── Diagnostics ───────────────────────────────────────────────────────────
435 volatile uint32_t _lastSampleMs;
436 uint32_t _totalSamples;
437
438 // ── Callbacks ─────────────────────────────────────────────────────────────
439 SenseCb _senseCb;
440 Politician::PacketCb _userPacketCb;
441
442 // ── Internal helpers ──────────────────────────────────────────────────────
443
444 void _reset_internal() {
445 portENTER_CRITICAL_SAFE(&_mux);
446 _head = 0;
447 _count = 0;
448 _totalSamples = 0;
449 _lastSampleMs = 0;
450 portEXIT_CRITICAL_SAFE(&_mux);
451 _mean = 0.0f;
452 _variance = 0.0f;
453 _state = SENSE_STILL;
454 _lastMotionMs = 0;
455 }
456
457 void _onPacket(const uint8_t *payload, uint16_t len,
458 int8_t rssi, uint8_t ch, uint32_t ts) {
459 if (!_active) return; // Guard against in-flight calls after end()/destructor.
460 if (len >= sizeof(ieee80211_hdr_t)) {
461 const auto *hdr = reinterpret_cast<const ieee80211_hdr_t *>(payload);
462 uint16_t fc = hdr->frame_ctrl;
463 // Accept only beacon frames. Probe responses, EAPOLs, and data frames
464 // from the same BSSID have different RSSI signatures and contaminate
465 // the variance stream, causing false motion detections.
466 bool isBeacon = ((fc & FC_TYPE_MASK) == FC_TYPE_MGMT) &&
468 if (isBeacon) {
469 uint32_t now = millis();
470 // Single critical section: anchor check and sample write are atomic,
471 // preventing a torn anchor read if begin() reconfigures concurrently.
472 portENTER_CRITICAL_SAFE(&_mux);
473 if (_anyAnchor || memcmp(hdr->addr2, _anchor, 6) == 0) {
474 _buf[_head] = rssi;
475 _head = (_head + 1) % _windowSize;
476 if (_count < _windowSize) _count++;
477 _totalSamples++;
478 _lastSampleMs = now;
479 }
480 portEXIT_CRITICAL_SAFE(&_mux);
481 }
482 }
483 if (_userPacketCb) _userPacketCb(payload, len, rssi, ch, ts);
484 }
485};
486
487} // namespace politician
#define POLITICIAN_SENSE_MAX_WINDOW
Maximum ring-buffer capacity (samples).
#define MGMT_SUB_BEACON
Definition Politician.h:71
#define FC_TYPE_MASK
Definition Politician.h:58
#define FC_SUBTYPE_MASK
Definition Politician.h:59
#define FC_TYPE_MGMT
Definition Politician.h:62
Passive RSSI-based motion and presence detector.
void setStaleTimeout(uint32_t ms)
Sets the stale-data timeout (ms).
void setSenseCallback(SenseCb cb)
Sets the state-change callback.
void end()
Detaches from the engine and clears its packet logger.
void setThreshold(float dBm2)
Sets the variance threshold (dBm²) that triggers SENSE_MOTION.
static constexpr uint32_t DEFAULT_DEBOUNCE
Motion hold-time (ms).
static constexpr uint8_t DEFAULT_WINDOW
Sliding window (samples).
void setPacketLogger(Politician::PacketCb cb)
Registers a pass-through raw-packet callback.
static constexpr float DEFAULT_THRESHOLD
Variance threshold (dBm²).
void setDebounce(uint32_t ms)
Sets the debounce hold-time (ms).
static constexpr uint32_t DEFAULT_STALE_MS
Max gap before ignoring stale data (ms).
void begin(Politician &engine, const uint8_t *anchorBssid=nullptr)
Attaches the sensor to a Politician engine.
const uint8_t * getAnchor() const
void setWindowSize(uint8_t n)
Sets the sliding window size in samples (clamped to [4, POLITICIAN_SENSE_MAX_WINDOW]).
void reset()
Clears the sample window and resets state without detaching from the engine.
void tick()
Main worker — call from loop() alongside engine.tick().
bool beginBySSID(Politician &engine, const char *ssid)
Looks up an AP by SSID in the engine cache and anchors to its BSSID.
The core WiFi handshake capturing engine.
Definition Politician.h:103
void setPacketLogger(PacketCb cb)
Sets the callback for raw promiscuous mode packets.
Definition Politician.h:427
std::function< void(const uint8_t *payload, uint16_t len, int8_t rssi, uint8_t channel, uint32_t ts_usec)> PacketCb
Definition Politician.h:359
Politician engine
Definition main.cpp:6
SenseEvent
State transition delivered to the SenseCb callback.
@ SENSE_MOTION
RSSI variance spiked above threshold — movement detected.
@ SENSE_STILL
RSSI variance returned to baseline — area quiet.
std::function< void(SenseEvent event, float variance)> SenseCb
Callback fired on SENSE_STILL ↔ SENSE_MOTION transitions.
Snapshot of a discovered Access Point from the internal cache.
uint32_t millis()