¿Es posible detectar que una persona se está moviendo sin utilizar una cámara, un sensor de movimiento o un dispositivo que lleve la persona?
Sí. Una de las técnicas que permite hacerlo es el Wi-Fi Sensing, basado en el análisis de la información del estado del canal inalámbrico, conocida como CSI (Channel State Information).
En este experimento se utiliza un ESP32-S3, un router Wi-Fi convencional y Python para observar cómo cambia la señal cuando una persona se mueve.
¿Qué es CSI?
Cuando un dispositivo recibe una transmisión Wi-Fi no solamente puede saber si los datos han llegado correctamente. También puede obtener información sobre cómo se ha propagado la señal entre el transmisor y el receptor.
Esta información se denomina:
Channel State Information (CSI).
De forma simplificada, podemos representar el sistema así:
Router Wi-Fi
│
│ señal inalámbrica
│
▼
ESP32-S3
│
▼
CSI
El CSI contiene información relacionada con diferentes componentes de la señal recibida. En nuestro experimento trabajamos principalmente con valores I/Q (In-phase / Quadrature).
A partir de ellos podemos obtener la amplitud:
De esta forma podemos transformar los datos CSI en una representación que permite estudiar cómo cambia el canal inalámbrico.
Una señal Wi-Fi no sigue un único camino
Una de las claves para entender este sistema es que las ondas Wi-Fi no viajan únicamente en línea recta desde el router hasta el ESP32.
En una habitación existen diferentes caminos de propagación:
pared
╱
╱
Router ──────────────╱──────> ESP32
╲
╲
╲────── mesa ─────> ESP32
También pueden producirse reflexiones y dispersiones debido a muebles, paredes, objetos y personas.
Por tanto, la señal que finalmente recibe el ESP32 es el resultado de la combinación de diferentes componentes.
¿Qué ocurre cuando aparece una persona?
Aquí está la parte más interesante del experimento.
Imaginemos inicialmente una habitación vacía:
Router ───────────────────── ESP32
El entorno permanece relativamente estable y el patrón CSI también puede mantenerse relativamente estable.
Ahora una persona se coloca entre ambos dispositivos:
Router ───────── 👤 ───────── ESP32
El cuerpo de la persona modifica la propagación de las ondas.
Se producen nuevos caminos y cambian algunos de los existentes.
Si posteriormente la persona se desplaza:
Router ──────── 👤 → ─────── ESP32
el canal inalámbrico vuelve a cambiar.
El resultado es que:
MOVIMIENTO
↓
cambio de propagación
↓
cambio del canal Wi-Fi
↓
cambio del CSI
No estamos viendo a la persona. Estamos observando la perturbación que produce en el canal inalámbrico.
¿Por qué no basta con mirar el RSSI?
El RSSI proporciona una medida general de la potencia de la señal recibida.
Por ejemplo:
RSSI = -46 dBm
Pero podemos tener valores RSSI muy similares mientras el CSI presenta variaciones importantes.
En las capturas obtenidas durante el experimento aparecen muestras con RSSI alrededor de −45/−46 dBm mientras los valores CSI cambian entre capturas.
Por eso podemos simplificar la diferencia de esta forma:
RSSI
↓
¿Cuánta señal recibo?
CSI
↓
¿Cómo está llegando esa señal?
CSI proporciona mucha más información para estudiar las modificaciones del canal.
El experimento con ESP32-S3
Para realizar la prueba se utilizan:
- un router Wi-Fi;
- un ESP32-S3;
- un ordenador Mac;
- Python;
- tráfico UDP;
- CSI proporcionado por el ESP32.
El esquema es:
Wi-Fi
┌──────────────┐
│ ROUTER │
└──────┬───────┘
│
│
▼
┌─────────┐
│ ESP32-S3│
└────┬────┘
│
USB
│
▼
Python
│
▼
procesamiento
│
▼
detección de
actividad
El ordenador genera tráfico UDP hacia el ESP32 para provocar transmisiones Wi-Fi frecuentes.
Durante la prueba se llegaron a enviar 152.278 paquetes UDP en 20 segundos.
El ESP32 obtiene información CSI de los paquetes recibidos.
En las muestras obtenidas aparecen CSI de hasta 384 bytes y valores I/Q que cambian entre capturas.
De los datos CSI a la detección de movimiento
El proceso que estamos utilizando puede resumirse en varias etapas.
1. Capturar CSI
El ESP32 obtiene:
I,Q,I,Q,I,Q,...
junto con información como:
RSSI
canal
MCS
longitud CSI
2. Calcular amplitud
Para cada par I/Q:
Obtenemos así una representación de la amplitud de los diferentes componentes de la señal.
3. Crear una referencia
Cuando la habitación permanece estable, el programa construye una referencia:
CSI actual
CSI actual
CSI actual
CSI actual
↓
BASELINE
Esta referencia representa aproximadamente cómo se comporta el canal cuando no existe un movimiento significativo.
4. Comparar con la referencia
Para cada nueva captura calculamos una diferencia:
Cuando la diferencia es pequeña:
D ↓
el canal permanece relativamente estable.
Cuando aumenta:
D ↑
existe una modificación del canal que puede estar relacionada con movimiento u otros cambios ambientales.
Proyecto real
En este proyecto se construye un sistema de Wi-Fi Sensing utilizando un ESP32-S3, un router Wi-Fi y un ordenador. El ESP32 captura información CSI (Channel State Information) y un programa Python procesa las muestras para representar los cambios del canal inalámbrico y detectar movimiento.
La instalación utiliza el ejemplo csi_recv_router del proyecto oficial esp-csi de Espressif.
Importante: este proyecto trabaja con las características del canal radioeléctrico. La detección de movimiento es experimental y depende de la posición del ESP32, el router, las paredes, el ruido radioeléctrico y otros factores. No debe considerarse un sistema de seguridad ni un detector de presencia certificado.
1. Material necesario
Para reproducir el proyecto se necesita:
- ESP32-S3-DevKitC-1.
- Cable USB-C con transmisión de datos.
- Mac u ordenador con Python.
- Router Wi-Fi de 2,4 GHz.
- Red Wi-Fi a la que pueda conectarse el ESP32.
- Conexión USB entre el ESP32 y el ordenador.
La arquitectura es:
ROUTER Wi-Fi
│
│ Wi-Fi
▼
ESP32-S3
│
│ USB
▼
MAC
│
▼
Python + NumPy
│
▼
Procesamiento
│
▼
Detección de
movimiento
2. Software necesario
El proyecto utiliza:
- ESP-IDF 6.0
- esp-csi
- Python 3
- PySerial
- NumPy
- Matplotlib
- Git
En este proyecto se ha utilizado un ESP32-S3 y ESP-IDF 6.0.
3. Instalar Homebrew en macOS
Si Homebrew no está instalado:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
Comprobar:
brew --version
4. Instalar las herramientas necesarias
Ejecutar:
brew install git cmake ninja python
Comprobar:
git --version
cmake --version
ninja --version
python3 --version
5. Crear el directorio para ESP-IDF
mkdir -p ~/esp
cd ~/esp
6. Descargar ESP-IDF 6.0
Clonar la versión 6.0:
git clone --branch release/v6.0 --depth 1 https://github.com/espressif/esp-idf.git esp-idf-v6.0
Entrar:
cd ~/esp/esp-idf-v6.0
7. Instalar ESP-IDF
Ejecutar:
./install.sh esp32s3
Cuando termine:
source export.sh
Comprobar:
idf.py --version
Debe aparecer una versión de ESP-IDF 6.0.x.
Cada vez que se abra un terminal nuevo será necesario activar el entorno:
cd ~/esp/esp-idf-v6.0
source export.sh
8. Descargar esp-csi
El proyecto utiliza el repositorio oficial de Espressif:
cd ~/esp
git clone https://github.com/espressif/esp-csi.git
Comprobar:
ls ~/esp/esp-csi
9. Acceder al ejemplo utilizado
El proyecto utiliza:
esp-csi/examples/get-started/csi_recv_router
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 |
#include <stdio.h> #include <string.h> #include <inttypes.h> #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "nvs_flash.h" #include "esp_log.h" #include "esp_system.h" #include "esp_wifi.h" #include "esp_netif.h" #include "esp_event.h" #include "esp_err.h" #include "protocol_examples_common.h" #include "esp_csi_gain_ctrl.h" static const char *TAG = "CSI_STREAM"; static volatile uint32_t s_csi_count = 0; /* * ============================================================ * CALLBACK CSI * ============================================================ * * Formato de salida: * * CSI,timestamp,rssi,channel,mcs,len,dato1,dato2,... * * Los datos CSI del ESP32 están codificados como pares: * * I,Q,I,Q,I,Q,... * */ static void wifi_csi_rx_cb(void *ctx, wifi_csi_info_t *info) { if (info == NULL) { return; } if (info->buf == NULL || info->len <= 0) { return; } s_csi_count++; /* * Timestamp en microsegundos. */ uint32_t timestamp = info->rx_ctrl.timestamp; /* * Cabecera. */ printf( "CSI,%" PRIu32 ",%d,%d,%d,%d", timestamp, info->rx_ctrl.rssi, info->rx_ctrl.channel, info->rx_ctrl.mcs, info->len ); /* * CSI completo. * * Cada elemento se envía como entero separado por coma. */ for (int i = 0; i < info->len; i++) { printf( ",%d", info->buf[i] ); } printf("\n"); } /* * ============================================================ * CONFIGURACIÓN CSI * ============================================================ */ static esp_err_t wifi_csi_init(void) { wifi_csi_config_t csi_config = { .lltf_en = true, .htltf_en = true, .stbc_htltf2_en = true, .ltf_merge_en = true, .channel_filter_en = false, .manu_scale = false, .shift = false, }; ESP_LOGI( TAG, "Registrando callback CSI..." ); esp_err_t ret; /* * No filtramos por BSSID. */ ret = esp_wifi_set_csi_rx_cb( wifi_csi_rx_cb, NULL ); if (ret != ESP_OK) { ESP_LOGE( TAG, "esp_wifi_set_csi_rx_cb ERROR: %s", esp_err_to_name(ret) ); return ret; } ESP_LOGI( TAG, "CSI CALLBACK OK" ); /* * Configuración CSI. */ ret = esp_wifi_set_csi_config( &csi_config ); if (ret != ESP_OK) { ESP_LOGE( TAG, "esp_wifi_set_csi_config ERROR: %s", esp_err_to_name(ret) ); return ret; } ESP_LOGI( TAG, "CSI CONFIG OK" ); /* * Activar CSI. */ ret = esp_wifi_set_csi(true); if (ret != ESP_OK) { ESP_LOGE( TAG, "esp_wifi_set_csi ERROR: %s", esp_err_to_name(ret) ); return ret; } ESP_LOGI( TAG, "CSI ACTIVADO" ); return ESP_OK; } /* * ============================================================ * INFORMACIÓN WIFI * ============================================================ */ static void print_wifi_info(void) { wifi_ap_record_t ap_info; memset( &ap_info, 0, sizeof(ap_info) ); esp_err_t ret = esp_wifi_sta_get_ap_info( &ap_info ); if (ret != ESP_OK) { ESP_LOGW( TAG, "No se pudo obtener información del AP" ); return; } ESP_LOGI( TAG, "SSID: %s", (char *)ap_info.ssid ); ESP_LOGI( TAG, "BSSID: %02x:%02x:%02x:%02x:%02x:%02x", ap_info.bssid[0], ap_info.bssid[1], ap_info.bssid[2], ap_info.bssid[3], ap_info.bssid[4], ap_info.bssid[5] ); ESP_LOGI( TAG, "CANAL: %d", ap_info.primary ); ESP_LOGI( TAG, "RSSI: %d", ap_info.rssi ); } /* * ============================================================ * APP MAIN * ============================================================ */ void app_main(void) { ESP_LOGI( TAG, "========================================" ); ESP_LOGI( TAG, " ESP32-S3 CSI STREAM" ); ESP_LOGI( TAG, "========================================" ); /* * -------------------------------------------------------- * NVS * -------------------------------------------------------- */ esp_err_t ret = nvs_flash_init(); if ( ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND ) { ESP_ERROR_CHECK( nvs_flash_erase() ); ret = nvs_flash_init(); } ESP_ERROR_CHECK(ret); /* * -------------------------------------------------------- * NETIF * -------------------------------------------------------- */ ESP_ERROR_CHECK( esp_netif_init() ); /* * -------------------------------------------------------- * EVENT LOOP * -------------------------------------------------------- */ ESP_ERROR_CHECK( esp_event_loop_create_default() ); /* * -------------------------------------------------------- * WIFI * -------------------------------------------------------- */ ESP_LOGI( TAG, "Conectando al router..." ); ESP_ERROR_CHECK( example_connect() ); ESP_LOGI( TAG, "Wi-Fi conectado" ); /* * Información del AP. */ print_wifi_info(); /* * -------------------------------------------------------- * DESACTIVAR POWER SAVE * -------------------------------------------------------- */ ret = esp_wifi_set_ps( WIFI_PS_NONE ); if (ret == ESP_OK) { ESP_LOGI( TAG, "Power Save DESACTIVADO" ); } else { ESP_LOGW( TAG, "No se pudo desactivar Power Save: %s", esp_err_to_name(ret) ); } /* * -------------------------------------------------------- * PROMISCUOUS OFF * -------------------------------------------------------- */ ret = esp_wifi_set_promiscuous( false ); if (ret == ESP_OK) { ESP_LOGI( TAG, "Promiscuous OFF" ); } else { ESP_LOGW( TAG, "No se pudo desactivar promiscuous" ); } /* * -------------------------------------------------------- * CSI * -------------------------------------------------------- */ ESP_ERROR_CHECK( wifi_csi_init() ); /* * -------------------------------------------------------- * ESTADO * -------------------------------------------------------- */ ESP_LOGI( TAG, "========================================" ); ESP_LOGI( TAG, "CSI STREAM ACTIVO" ); ESP_LOGI( TAG, "Esperando tráfico Wi-Fi..." ); ESP_LOGI( TAG, "========================================" ); /* * -------------------------------------------------------- * BUCLE PRINCIPAL * -------------------------------------------------------- */ while (1) { vTaskDelay( pdMS_TO_TICKS(1000) ); } } |
Entrar:
cd ~/esp/esp-csi/examples/get-started/csi_recv_router
Comprobar:
ls
10. Conectar el ESP32-S3
Conectar el ESP32-S3 al ordenador mediante USB.
Comprobar el puerto:
ls /dev/cu.*
En macOS debería aparecer un puerto similar a:
/dev/cu.usbmodem1101
El nombre puede ser diferente en otro ordenador.
Por ejemplo:
/dev/cu.usbmodem101
o:
/dev/cu.usbmodem1101
Hay que utilizar el puerto que aparezca realmente en el sistema.
11. Configurar el proyecto
Desde:
cd ~/esp/esp-csi/examples/get-started/csi_recv_router
ejecutar:
idf.py set-target esp32s3
Después:
idf.py menuconfig
Comprobar que CSI está habilitado.
Buscar:
Component config
→ Wi-Fi
y activar:
WiFi CSI
Debe quedar:
[*] WiFi CSI
Guardar la configuración y salir.
12. Configuración del router
El ESP32 debe conectarse a la red Wi-Fi.
En el ejemplo csi_recv_router, la conexión se realiza mediante:
example_connect()
Por tanto, las credenciales Wi-Fi se configuran mediante la configuración del proyecto de ejemplo.
Es importante utilizar:
- una red de 2,4 GHz;
- un canal Wi-Fi estable;
- preferiblemente ancho de banda de 20 MHz durante las pruebas.
En mi instalación de referencia se utilizó:
SSID: MOVISTAR
Canal: 8
Ancho de banda: 20 MHz
No copies las credenciales Wi-Fi de una instalación concreta en un proyecto público. Cada usuario debe utilizar las de su propia red.
13. Firmware utilizado
El firmware del ESP32 utiliza el ejemplo csi_recv_router.
La función principal del firmware es:
conectar ESP32 al router
↓
activar CSI
↓
recibir información CSI
↓
ejecutar callback
↓
enviar las muestras por USB
La configuración CSI utilizada en el experimento es:
wifi_csi_config_t csi_config = {
.lltf_en = true,
.htltf_en = true,
.stbc_htltf2_en = true,
.ltf_merge_en = true,
.channel_filter_en = false,
.manu_scale = false,
.shift = false,
};
El callback genera líneas con el siguiente formato:
CSI,timestamp,rssi,channel,mcs,len,dato1,dato2,dato3,...
Los datos CSI se reciben como pares:
I,Q,I,Q,I,Q,...
14. Compilar el firmware
Desde el directorio del proyecto:
cd ~/esp/esp-csi/examples/get-started/csi_recv_router
Ejecutar:
idf.py build
Si la compilación termina correctamente aparecerá un mensaje indicando que el proyecto se ha construido.
15. Grabar el firmware
Con el ESP32 conectado:
idf.py -p /dev/cu.usbmodem1101 flash
Si el puerto de tu ordenador es diferente, sustituye:
/dev/cu.usbmodem1101
por el puerto correspondiente.
16. Abrir el monitor serie
Después de grabar:
idf.py -p /dev/cu.usbmodem1101 monitor
El ESP32 debería conectarse a la red.
La salida será similar a:
CSI CALLBACK OK
CSI CONFIG OK
CSI ACTIVADO
CSI STREAM ACTIVO
Esperando tráfico Wi-Fi...
También debería mostrar información del punto de acceso:
SSID
BSSID
CANAL
RSSI
17. Comprobar que el ESP32 está conectado
El ESP32 debe obtener una dirección IP.
En mi instalación se obtuvo:
192.168.1.123
Pero cada red asignará una IP diferente.
Para conocer la IP asignada se puede consultar la salida del monitor serie o la lista de dispositivos conectados del router.
Desde el ordenador:
ping 192.168.1.123
Sustituir la IP por la que haya recibido el ESP32.
Debe responder:
64 bytes from 192.168.1.123
18. Comprobar que se reciben datos CSI
Con tráfico Wi-Fi suficiente aparecerán líneas como:
CSI,....,-49,8,7,384,...
Los campos representan:
CSI
timestamp
RSSI
canal
MCS
longitud CSI
datos CSI
Los datos finales son los valores I/Q.
19. Cerrar el monitor serie
Antes de ejecutar Python hay que cerrar:
idf.py monitor
porque Python también utilizará el puerto USB.
Para salir del monitor:
Ctrl+]
20. Preparar Python
Crear un entorno virtual:
python3 -m venv ~/.wifi-sensing-venv
Activarlo:
source ~/.wifi-sensing-venv/bin/activate
Instalar las librerías:
pip install --upgrade pip
pip install pyserial numpy matplotlib
Comprobar:
python3 -c "import serial, numpy, matplotlib; print('OK')"
Debe aparecer:
OK
21. Crear el programa Python
Crear:
nano ~/csi_reader.py
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 |
#!/usr/bin/env python3 import serial import numpy as np import matplotlib.pyplot as plt import time import threading from collections import deque # ============================================================ # CONFIGURACIÓN # ============================================================ SERIAL_PORT = "/dev/cu.usbmodem1101" BAUDRATE = 921600 # IP del ESP32 ESP32_IP = "192.168.1.123" # Puerto UDP que escucha el ESP32 UDP_PORT = 5201 # Tamaño de los paquetes UDP UDP_PACKET_SIZE = 1400 # Paquetes UDP por segundo. # # No necesitamos 150.000 paquetes en 20 segundos. # Un tráfico constante de unos cientos de paquetes/s # es suficiente para provocar tráfico Wi-Fi estable. UDP_PACKETS_PER_SECOND = 300 # Número máximo de muestras que conservamos HISTORY_LENGTH = 500 # Número de muestras utilizadas para establecer # la referencia inicial. BASELINE_SAMPLES = 100 # Suavizado de la referencia. BASELINE_ALPHA = 0.015 # Suavizado de la métrica de actividad. ACTIVITY_ALPHA = 0.20 # Ignorar amplitudes demasiado pequeñas AMPLITUDE_THRESHOLD = 2.0 # ============================================================ # VARIABLES GLOBALES # ============================================================ running = True latest_amplitude = None latest_difference = None baseline = None activity_value = 0.0 packet_counter = 0 last_rssi = 0 last_channel = 0 last_mcs = 0 csi_rate = 0.0 activity_history = deque( maxlen=HISTORY_LENGTH ) rate_history = deque( maxlen=HISTORY_LENGTH ) difference_history = deque( maxlen=HISTORY_LENGTH ) timestamp_history = deque( maxlen=HISTORY_LENGTH ) lock = threading.Lock() # ============================================================ # LECTOR UDP # ============================================================ def udp_sender(): """ Genera tráfico UDP continuo hacia el ESP32. El objetivo es que el router transmita continuamente paquetes Wi-Fi hacia el ESP32 para producir CSI. """ import socket global running sock = socket.socket( socket.AF_INET, socket.SOCK_DGRAM ) data = b"X" * UDP_PACKET_SIZE interval = ( 1.0 / UDP_PACKETS_PER_SECOND ) print() print( f"[UDP] Enviando tráfico a " f"{ESP32_IP}:{UDP_PORT}" ) print( f"[UDP] {UDP_PACKETS_PER_SECOND} paquetes/s" ) print() next_send = time.perf_counter() while running: try: sock.sendto( data, ( ESP32_IP, UDP_PORT ) ) except Exception as e: print( f"[UDP] Error: {e}" ) break next_send += interval delay = ( next_send - time.perf_counter() ) if delay > 0: time.sleep( delay ) else: next_send = time.perf_counter() sock.close() print( "[UDP] Envío detenido" ) # ============================================================ # LECTOR SERIAL # ============================================================ def serial_reader(ser): """ Lee las líneas CSI generadas por el ESP32. """ global running global latest_amplitude global latest_difference global baseline global activity_value global packet_counter global last_rssi global last_channel global last_mcs last_rate_time = time.time() last_rate_packets = 0 while running: try: line = ser.readline() if not line: continue text = line.decode( "utf-8", errors="ignore" ).strip() if not text.startswith( "CSI," ): continue fields = text.split(",") if len(fields) < 7: continue try: timestamp = int( fields[1] ) rssi = int( fields[2] ) channel = int( fields[3] ) mcs = int( fields[4] ) csi_length = int( fields[5] ) values = np.asarray( [ int(x) for x in fields[6:] ], dtype=np.float32 ) except ValueError: continue # ------------------------------------------------ # Comprobar longitud # ------------------------------------------------ if csi_length <= 0: continue if len(values) < csi_length: continue values = values[ :csi_length ] # ------------------------------------------------ # I/Q # ------------------------------------------------ # # CSI: # # I,Q,I,Q,I,Q,... # # ------------------------------------------------ usable_length = ( len(values) // 2 ) * 2 values = values[ :usable_length ] if len(values) < 4: continue iq = values.reshape( -1, 2 ) I = iq[:, 0] Q = iq[:, 1] # ------------------------------------------------ # Amplitud # ------------------------------------------------ amplitude = np.sqrt( I * I + Q * Q ) # ------------------------------------------------ # Eliminar zonas sin señal # ------------------------------------------------ # # En las capturas del ESP32 aparecen muchas # posiciones con I=0 y Q=0. # # No queremos que esas posiciones dominen # la métrica de movimiento. # ------------------------------------------------ valid_mask = ( amplitude > AMPLITUDE_THRESHOLD ) if np.sum(valid_mask) < 10: continue # ------------------------------------------------ # Primera referencia # ------------------------------------------------ if baseline is None: baseline = amplitude.copy() latest_amplitude = amplitude.copy() latest_difference = np.zeros_like( amplitude ) continue # Si cambia la longitud del CSI if len(baseline) != len(amplitude): baseline = amplitude.copy() latest_amplitude = amplitude.copy() latest_difference = np.zeros_like( amplitude ) continue # ------------------------------------------------ # Diferencia respecto a referencia # ------------------------------------------------ difference = np.abs( amplitude - baseline ) # ------------------------------------------------ # Métrica robusta de actividad # ------------------------------------------------ # # Usamos percentiles para evitar que unos pocos # subportadores extremadamente variables dominen # la medida. # ------------------------------------------------ valid_difference = ( difference[ valid_mask ] ) if len(valid_difference) > 10: p50 = np.percentile( valid_difference, 50 ) p75 = np.percentile( valid_difference, 75 ) p90 = np.percentile( valid_difference, 90 ) activity_raw = ( 0.50 * p50 + 0.30 * p75 + 0.20 * p90 ) else: activity_raw = float( np.mean( valid_difference ) ) # ------------------------------------------------ # Suavizado actividad # ------------------------------------------------ activity_value = ( (1.0 - ACTIVITY_ALPHA) * activity_value + ACTIVITY_ALPHA * activity_raw ) # ------------------------------------------------ # Actualizar baseline # ------------------------------------------------ # # Muy lentamente. # # Esto permite detectar movimientos rápidos sin # que la referencia se adapte inmediatamente. # ------------------------------------------------ baseline = ( (1.0 - BASELINE_ALPHA) * baseline + BASELINE_ALPHA * amplitude ) # ------------------------------------------------ # Guardar información # ------------------------------------------------ latest_amplitude = ( amplitude.copy() ) latest_difference = ( difference.copy() ) with lock: packet_counter += 1 last_rssi = rssi last_channel = channel last_mcs = mcs activity_history.append( activity_value ) difference_history.append( float( np.mean( valid_difference ) ) ) timestamp_history.append( timestamp ) # ------------------------------------------------ # Calcular frecuencia CSI # ------------------------------------------------ now = time.time() elapsed = ( now - last_rate_time ) if elapsed >= 1.0: packets_now = ( packet_counter - last_rate_packets ) rate = ( packets_now / elapsed ) csi_rate = rate last_rate_packets = ( packet_counter ) last_rate_time = now except Exception as e: if running: print( f"[SERIAL] Error: {e}" ) time.sleep( 0.1 ) # ============================================================ # FUNCIÓN DE COLOR DE ACTIVIDAD # ============================================================ def activity_status(value): """ Convierte la métrica CSI en un estado orientativo. IMPORTANTE: Los umbrales son adaptativos y deben ajustarse posteriormente a la instalación concreta. """ if value < 2.0: return "QUIETO" elif value < 5.0: return "CAMBIO" elif value < 10.0: return "MOVIMIENTO" else: return "MOVIMIENTO FUERTE" # ============================================================ # MAIN # ============================================================ def main(): global running print( "==============================================" ) print( " ESP32-S3 CSI MOVEMENT SENSOR" ) print( "==============================================" ) print() print( f"Puerto serie: {SERIAL_PORT}" ) print( f"ESP32: {ESP32_IP}" ) print( f"UDP: {UDP_PORT}" ) print() print( "IMPORTANTE:" ) print( "Cierra idf.py monitor antes de ejecutar." ) print() # -------------------------------------------------------- # Abrir puerto # -------------------------------------------------------- try: ser = serial.Serial( SERIAL_PORT, BAUDRATE, timeout=1 ) except Exception as e: print( "ERROR abriendo puerto serie:" ) print( e ) return print( "Puerto serie abierto." ) print() # -------------------------------------------------------- # Hilo serial # -------------------------------------------------------- serial_thread = threading.Thread( target=serial_reader, args=(ser,), daemon=True ) serial_thread.start() # -------------------------------------------------------- # Hilo UDP # -------------------------------------------------------- udp_thread = threading.Thread( target=udp_sender, daemon=True ) udp_thread.start() # -------------------------------------------------------- # Crear gráfica # -------------------------------------------------------- plt.ion() fig = plt.figure( figsize=(13, 9) ) ax1 = fig.add_subplot( 3, 1, 1 ) ax2 = fig.add_subplot( 3, 1, 2 ) ax3 = fig.add_subplot( 3, 1, 3 ) # -------------------------------------------------------- # Líneas # -------------------------------------------------------- line_amplitude, = ax1.plot( [], linewidth=1 ) line_baseline, = ax1.plot( [], linewidth=1 ) line_difference, = ax2.plot( [], linewidth=1 ) line_activity, = ax3.plot( [], linewidth=2 ) # -------------------------------------------------------- # CSI # -------------------------------------------------------- ax1.set_title( "CSI - Amplitud actual y referencia" ) ax1.set_xlabel( "Subportadora" ) ax1.set_ylabel( "Amplitud" ) ax1.grid( True, alpha=0.3 ) # -------------------------------------------------------- # Diferencia # -------------------------------------------------------- ax2.set_title( "Variación respecto a la referencia" ) ax2.set_xlabel( "Subportadora" ) ax2.set_ylabel( "Diferencia" ) ax2.grid( True, alpha=0.3 ) # -------------------------------------------------------- # Actividad # -------------------------------------------------------- ax3.set_title( "Índice de actividad CSI" ) ax3.set_xlabel( "Muestras CSI" ) ax3.set_ylabel( "Actividad" ) ax3.grid( True, alpha=0.3 ) plt.tight_layout() # -------------------------------------------------------- # Bucle gráfico # -------------------------------------------------------- print() print( "Sistema iniciado." ) print() print( "PRUEBA:" ) print( "1. Quédate completamente quieto durante 15 segundos." ) print( "2. Después camina delante del enlace Wi-Fi." ) print( "3. Muévete durante 10-15 segundos." ) print( "4. Después vuelve a quedarte quieto." ) print() print( "Pulsa CTRL+C para terminar." ) print() try: while True: # ------------------------------------------------ # Copiar datos # ------------------------------------------------ with lock: amplitude = ( None if latest_amplitude is None else latest_amplitude.copy() ) difference = ( None if latest_difference is None else latest_difference.copy() ) activity = list( activity_history ) diff_history = list( difference_history ) count = packet_counter rssi = last_rssi channel = last_channel mcs = last_mcs rate = csi_rate # ------------------------------------------------ # CSI # ------------------------------------------------ if amplitude is not None: x = np.arange( len(amplitude) ) line_amplitude.set_data( x, amplitude ) if baseline is not None: line_baseline.set_data( x, baseline ) ax1.relim() ax1.autoscale_view() # ------------------------------------------------ # Diferencia # ------------------------------------------------ if difference is not None: x2 = np.arange( len(difference) ) line_difference.set_data( x2, difference ) ax2.relim() ax2.autoscale_view() # ------------------------------------------------ # Actividad # ------------------------------------------------ if len(activity) > 1: x3 = np.arange( len(activity) ) line_activity.set_data( x3, activity ) ax3.relim() ax3.autoscale_view() # ------------------------------------------------ # Estado # ------------------------------------------------ status = activity_status( activity[-1] if activity else 0.0 ) ax3.set_title( "Índice de actividad CSI | " f"{status} | " f"Valor: " f"{activity[-1]:.2f}" if activity else "Índice de actividad CSI" ) ax1.set_title( "CSI - Amplitud | " f"RSSI {rssi} dBm | " f"CH {channel} | " f"MCS {mcs} | " f"{rate:.1f} CSI/s" ) # ------------------------------------------------ # Refrescar # ------------------------------------------------ fig.canvas.draw_idle() fig.canvas.flush_events() plt.pause( 0.05 ) except KeyboardInterrupt: print() print( "Deteniendo..." ) finally: running = False time.sleep( 0.2 ) try: ser.close() except Exception: pass plt.close( "all" ) print( "Sistema detenido." ) # ============================================================ # EJECUCIÓN # ============================================================ if __name__ == "__main__": main() |
El programa debe realizar estas funciones:
USB Serial
↓
leer CSI
↓
extraer I/Q
↓
calcular amplitud
↓
comparar con baseline
↓
calcular actividad
↓
representar gráficamente
El programa utilizado en este proyecto también genera automáticamente tráfico UDP hacia el ESP32 para aumentar el tráfico Wi-Fi y obtener una cantidad estable de muestras CSI.
La configuración utilizada es:
SERIAL_PORT = "/dev/cu.usbmodem1101"
BAUDRATE = 921600
ESP32_IP = "192.168.1.123"
UDP_PORT = 5201
UDP_PACKET_SIZE = 1400
UDP_PACKETS_PER_SECOND = 300
El puerto serie y la IP deben modificarse para cada instalación. El programa actual ya incorpora el generador UDP, por lo que no es necesario ejecutar otro programa UDP simultáneamente.
22. Ejecutar el sistema
Activar el entorno:
source ~/.wifi-sensing-venv/bin/activate
Ejecutar:
python3 ~/csi_reader.py
El programa:
- abre el puerto USB;
- empieza a recibir CSI;
- genera tráfico UDP;
- procesa las muestras;
- calcula amplitudes;
- calcula diferencias respecto al baseline;
- calcula un índice de actividad;
- muestra una gráfica.
El envío UDP está integrado en el propio programa.
23. Qué hace el procesamiento
El ESP32 proporciona pares:
I,Q
Python calcula la amplitud:
El código utilizado actualmente realiza esta operación con:
amplitude = np.sqrt(I * I + Q * Q)
Después calcula la diferencia respecto a la referencia:
En el programa se utiliza:
difference = np.abs(amplitude - baseline)
24. ¿Por qué detecta movimiento?
La señal Wi-Fi no viaja únicamente en línea recta.
En una habitación existen múltiples trayectorias:
pared
/
/
Router ─────────► ESP32
\ /
\ /
► persona
La señal rebota en:
- paredes;
- muebles;
- suelo;
- techo;
- objetos;
- personas.
Esto produce multipath.
Cuando una persona se mueve, cambia la geometría de esas trayectorias.
Por tanto:
movimiento
↓
cambios en las trayectorias radio
↓
cambio del canal
↓
cambio del CSI
↓
cambio de amplitud
↓
mayor diferencia respecto al baseline
↓
actividad detectada
25. Colocar el ESP32
Una configuración sencilla para comenzar:
ESP32 PERSONA
📡 -------------------------- 🧍
Una distancia inicial razonable puede ser de unos pocos metros.
El router puede estar situado en otro punto de la habitación:
ROUTER
📡
/ \
/ \
/ \
🧍 \
\ \
\ 📡 ESP32
No es necesario que router y ESP32 estén perfectamente enfrentados.
26. Primera prueba
Al iniciar el programa:
- coloca el ESP32 en una posición fija;
- deja el router en una posición fija;
- evita mover el equipo durante la calibración;
- permanece quieto delante o cerca de la trayectoria;
- observa la gráfica.
La primera prueba recomendada es:
15 segundos quieto
↓
10-15 segundos moviéndose
↓
15 segundos quieto
El programa actual muestra instrucciones similares al iniciar.
27. Estados de movimiento
El programa actual utiliza cuatro estados:
QUIETO
CAMBIO
MOVIMIENTO
MOVIMIENTO FUERTE
Los umbrales actuales son:
Actividad < 2
→ QUIETO
Actividad < 5
→ CAMBIO
Actividad < 10
→ MOVIMIENTO
Actividad >= 10
→ MOVIMIENTO FUERTE
Estos valores son experimentales y dependen de la instalación.
28. Gráficas
El programa muestra tres gráficas:
1. Amplitud CSI
Representa la amplitud de las subportadoras.
CSI - Amplitud actual y referencia
2. Diferencia
Muestra cuánto se ha separado el CSI actual de la referencia.
Variación respecto a la referencia
3. Actividad
Representa la evolución temporal del índice de actividad.
Índice de actividad CSI
Estas tres representaciones están implementadas en el programa actual.
29. Probar el movimiento
Una vez iniciado el sistema:
Prueba 1: permanecer quieto
No te muevas durante varios segundos.
La señal debería ser relativamente estable.
Prueba 2: mover un brazo
Mueve lentamente un brazo.
Debería aparecer una variación del CSI.
Prueba 3: caminar
Camina delante del enlace radio.
La variación debería aumentar.
Prueba 4: volver a quedarse quieto
Al detenerte, la actividad debería disminuir progresivamente.
30. Problemas habituales
El puerto USB no aparece
Ejecutar:
ls /dev/cu.*
Desconectar y conectar nuevamente el ESP32.
También comprobar que el cable USB permite transferencia de datos.
Python dice que no puede abrir el puerto
Por ejemplo:
could not open port
Comprobar que no está abierto:
idf.py monitor
Cerrar el monitor con:
Ctrl+]
y volver a ejecutar:
python3 ~/csi_reader.py
No aparecen CSI
Comprobar primero que el ESP32 está conectado al router.
Después:
ping IP_DEL_ESP32
También comprobar en el monitor:
CSI CONFIG OK
CSI ACTIVADO
Hay pocos CSI
Generar tráfico Wi-Fi.
El csi_reader.py utilizado en este proyecto genera automáticamente aproximadamente:
300 paquetes UDP/s
hacia el ESP32.
El movimiento no se detecta bien
Probar:
- acercar o alejar el ESP32;
- cambiar la posición del router;
- cambiar la posición de la persona;
- reducir obstáculos;
- utilizar 20 MHz;
- mantener fija la posición del ESP32;
- realizar primero una calibración con la habitación quieta.
El comportamiento depende mucho de la geometría de la habitación.
31. Un detalle importante sobre las muestras CSI
Durante las pruebas pueden aparecer diferentes longitudes:
LEN 384
y:
LEN 128
Por ello no es conveniente asumir que todas las muestras tienen exactamente la misma estructura.
El programa actual comprueba la longitud antes de procesar los datos y reinicia la referencia cuando cambia la longitud.
Una versión posterior del proyecto puede separar ambas longitudes y construir un baseline más robusto.
32. Arquitectura final
Una vez funcionando todo:
INTERNET
│
│
┌────▼────┐
│ ROUTER │
│ Wi-Fi │
└────┬────┘
│
tráfico Wi-Fi
│
┌────▼────┐
│ ESP32-S3│
│ CSI │
└────┬────┘
│
USB-C
│
┌────▼────┐
│ MAC │
│ Python │
└────┬────┘
│
┌───────▼────────┐
│ NumPy │
│ │
│ I/Q │
│ ↓ │
│ Amplitud │
│ ↓ │
│ Baseline │
│ ↓ │
│ Diferencia │
│ ↓ │
│ Actividad │
└───────┬────────┘
│
┌────▼────┐
│Matplotlib│
└─────────┘
33. Comandos de uso diario
Una vez instalado todo, para repetir el experimento:
Terminal 1
cd ~/esp/esp-idf-v6.0
source export.sh
cd ~/esp/esp-csi/examples/get-started/csi_recv_router
idf.py -p /dev/cu.usbmodem1101 flash
Si no necesitas volver a grabar el firmware, simplemente:
idf.py -p /dev/cu.usbmodem1101 monitor
Comprueba:
CSI ACTIVADO
CSI STREAM ACTIVO
Sal:
Ctrl+]
Terminal 2
source ~/.wifi-sensing-venv/bin/activate
python3 ~/csi_reader.py
34. Esquema resumido
1. Instalar ESP-IDF
↓
2. Descargar esp-csi
↓
3. Configurar ESP32-S3
↓
4. Activar Wi-Fi CSI
↓
5. Compilar
↓
6. Grabar ESP32
↓
7. Conectar ESP32 al router
↓
8. Comprobar CSI
↓
9. Cerrar monitor
↓
10. Instalar Python
↓
11. Ejecutar csi_reader.py
↓
12. Generar tráfico UDP
↓
13. Recibir CSI
↓
14. Calcular I/Q
↓
15. Calcular amplitud
↓
16. Comparar con baseline
↓
17. Obtener índice de actividad
↓
18. Detectar movimiento
Resultado
Con esta configuración se obtiene un sistema experimental de Wi-Fi Sensing basado en CSI, donde el ESP32-S3 no necesita una cámara ni un sensor de movimiento convencional. El dispositivo captura variaciones del canal Wi-Fi y Python analiza esas variaciones para representar la actividad.
La parte especialmente interesante del proyecto es que no se está midiendo simplemente RSSI: el ESP32 está obteniendo información CSI con componentes I/Q de diferentes subportadoras y el programa analiza cómo cambia esa información cuando se modifica la propagación de la señal.