Add HTTP/serial file transfer, broadcast beacon, SD telemetry

- HTTP server on port 8080: GET /sdcard/<path> for SD file download
- Serial: sdcard send <file> streams hex-encoded; tools/sdcard_recv.py receives
- Broadcast beacon (UDP 5555): advertise IP, MAC, fiwi-telemetry for discovery
- tools/beacon_listen.py: listen and optionally download fiwi-telemetry per device
- SD commands: list, send, delete; status shows fiwi-telemetry and telemetry-status
- Monitor start: MCS telemetry -> fiwi-telemetry on SD by default (every 10s)
- telemetry-status on SD: persist download stats across reboots
- Kconfig: BEACON_INTERVAL_SEC, TELEMETRY_AUTO_DELETE_AFTER_DOWNLOAD

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Robert McMahon 2026-02-07 12:30:04 -08:00
parent a4e81c9852
commit 1eb04acd25
20 changed files with 1802 additions and 7 deletions

View File

@ -10,7 +10,7 @@ idf_component_register(
"cmd_ip.c"
"cmd_sdcard.c"
INCLUDE_DIRS "."
REQUIRES console wifi_cfg sd_card
REQUIRES console wifi_cfg sd_card sdcard_http
wifi_controller iperf status_led gps_sync
esp_wifi esp_netif nvs_flash spi_flash
)

View File

@ -33,12 +33,29 @@
#include <stdio.h>
#include <string.h>
#include <inttypes.h>
#include "esp_console.h"
#include "argtable3/argtable3.h"
#include "app_console.h"
#include "sd_card.h"
#include "sdcard_http.h"
#define SDCARD_READ_BUF_SIZE 4096
#define SDCARD_SEND_CHUNK 256
#define SDCARD_SEND_MAX (512 * 1024) /* 512 KB max over serial */
/* Format bytes as human-readable (e.g. 1.2K, 4.5M). Writes into buf, max len chars. */
static void fmt_size_human(size_t bytes, char *buf, size_t len) {
if (bytes < 1024) {
snprintf(buf, len, "%zu B", bytes);
} else if (bytes < 1024 * 1024) {
snprintf(buf, len, "%.1f K", bytes / 1024.0);
} else if (bytes < 1024ULL * 1024 * 1024) {
snprintf(buf, len, "%.1f M", bytes / (1024.0 * 1024.0));
} else {
snprintf(buf, len, "%.1f G", bytes / (1024.0 * 1024.0 * 1024.0));
}
}
static int do_sdcard_status(int argc, char **argv) {
(void)argc;
@ -65,6 +82,23 @@ static int do_sdcard_status(int argc, char **argv) {
printf(" Total: %.2f MB\n", total / (1024.0 * 1024.0));
printf(" Free: %.2f MB\n", free_bytes / (1024.0 * 1024.0));
}
if (sd_card_file_exists("fiwi-telemetry")) {
size_t sz = 0;
if (sd_card_get_file_size("fiwi-telemetry", &sz) == 0) {
char hr[16];
fmt_size_human(sz, hr, sizeof(hr));
printf(" fiwi-telemetry: yes, %s (%zu bytes)\n", hr, sz);
} else {
printf(" fiwi-telemetry: yes, ?\n");
}
} else {
printf(" fiwi-telemetry: none\n");
}
uint32_t attempts = 0, downloads = 0;
sdcard_http_get_telemetry_stats(&attempts, &downloads);
printf(" telemetry HTTP: %" PRIu32 " attempts, %" PRIu32 " downloads\n", attempts, downloads);
printf(" telemetry-status: %s (timestamps + bytes per download)\n",
sd_card_file_exists("telemetry-status") ? "yes" : "none");
}
return 0;
}
@ -136,23 +170,133 @@ static int do_sdcard_read(int argc, char **argv) {
return 0;
}
/* Serial file transfer: output hex-encoded file for host script (e.g. sdcard_recv.py) */
static int do_sdcard_send(int argc, char **argv) {
if (argc < 2) {
printf("Usage: sdcard send <file>\n");
printf(" Streams file over serial (hex). Use tools/sdcard_recv.py on host to receive.\n");
return 1;
}
const char *filename = argv[1];
if (!sd_card_is_ready()) {
printf("Error: SD card not mounted\n");
return 1;
}
if (!sd_card_file_exists(filename)) {
printf("Error: File not found: %s\n", filename);
return 1;
}
size_t file_size = 0;
if (sd_card_get_file_size(filename, &file_size) != 0) {
printf("Error: Could not get file size\n");
return 1;
}
if (file_size > SDCARD_SEND_MAX) {
printf("Error: File too large for serial transfer (max %u KB)\n", (unsigned)(SDCARD_SEND_MAX / 1024));
return 1;
}
/* Protocol: ---SDFILE--- \n filename \n SIZE: N \n ---HEX--- \n <hex lines> ---END SDFILE--- */
printf("---SDFILE---\n%s\nSIZE:%zu\n---HEX---\n", filename, file_size);
fflush(stdout);
static uint8_t chunk[SDCARD_SEND_CHUNK];
size_t offset = 0;
while (offset < file_size) {
size_t to_read = file_size - offset;
if (to_read > sizeof(chunk)) {
to_read = sizeof(chunk);
}
size_t n = 0;
if (sd_card_read_file_at(filename, offset, chunk, to_read, &n) != 0 || n == 0) {
printf("\nError: Read failed at offset %zu\n", offset);
return 1;
}
for (size_t i = 0; i < n; i++) {
printf("%02x", (unsigned char)chunk[i]);
}
printf("\n");
fflush(stdout);
offset += n;
}
printf("---END SDFILE---\n");
fflush(stdout);
return 0;
}
static int do_sdcard_list(int argc, char **argv) {
const char *path = (argc >= 2) ? argv[1] : "";
if (!sd_card_is_ready()) {
printf("Error: SD card not mounted\n");
return 1;
}
printf("SD card: %s\n", path[0] ? path : "/");
esp_err_t ret = sd_card_list_dir(path);
if (ret != ESP_OK) {
printf("Error: Cannot list directory: %s\n", esp_err_to_name(ret));
return 1;
}
return 0;
}
static int do_sdcard_delete(int argc, char **argv) {
if (argc < 2) {
printf("Usage: sdcard delete <file>\n");
return 1;
}
const char *filename = argv[1];
if (!sd_card_is_ready()) {
printf("Error: SD card not mounted\n");
return 1;
}
if (!sd_card_file_exists(filename)) {
printf("Error: File not found: %s\n", filename);
return 1;
}
esp_err_t ret = sd_card_delete_file(filename);
if (ret != ESP_OK) {
printf("Error: Delete failed: %s\n", esp_err_to_name(ret));
return 1;
}
printf("Deleted: %s\n", filename);
return 0;
}
static int cmd_sdcard(int argc, char **argv) {
if (argc < 2) {
printf("Usage: sdcard <status|write|read> [args]\n");
printf("Usage: sdcard <status|list|write|read|send|delete> [args]\n");
printf(" status - Show CD, mounted, capacity\n");
printf(" list [path] - List files (path optional, default root)\n");
printf(" write <f> <t> - Write text to file\n");
printf(" read <f> - Read and print file\n");
printf(" send <f> - Stream file over serial (use tools/sdcard_recv.py)\n");
printf(" delete <f> - Delete a file\n");
return 0;
}
if (strcmp(argv[1], "status") == 0) {
return do_sdcard_status(argc - 1, &argv[1]);
}
if (strcmp(argv[1], "list") == 0 || strcmp(argv[1], "ls") == 0) {
return do_sdcard_list(argc - 1, &argv[1]);
}
if (strcmp(argv[1], "write") == 0) {
return do_sdcard_write(argc - 1, &argv[1]);
}
if (strcmp(argv[1], "read") == 0) {
return do_sdcard_read(argc - 1, &argv[1]);
}
if (strcmp(argv[1], "send") == 0) {
return do_sdcard_send(argc - 1, &argv[1]);
}
if (strcmp(argv[1], "delete") == 0 || strcmp(argv[1], "rm") == 0) {
return do_sdcard_delete(argc - 1, &argv[1]);
}
printf("Unknown subcommand '%s'\n", argv[1]);
return 1;
}
@ -160,8 +304,8 @@ static int cmd_sdcard(int argc, char **argv) {
void register_sdcard_cmd(void) {
const esp_console_cmd_t cmd = {
.command = "sdcard",
.help = "SD card: status (CD, capacity), write <file> <text>, read <file>",
.hint = "<status|write|read>",
.help = "SD card: status, list [path], write, read, send, delete <file>",
.hint = "<status|list|write|read|send|delete>",
.func = &cmd_sdcard,
};
ESP_ERROR_CHECK(esp_console_cmd_register(&cmd));

View File

@ -0,0 +1,6 @@
idf_component_register(
SRCS "mcs_telemetry.c"
INCLUDE_DIRS "."
REQUIRES esp_wifi esp_timer wifi_monitor
)

View File

@ -0,0 +1,371 @@
/*
* mcs_telemetry.c
*
* Copyright (c) 2026 Umber Networks & Robert McMahon
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
#include "mcs_telemetry.h"
#include "wifi_monitor.h"
#include "esp_log.h"
#include "esp_timer.h"
#include <string.h>
#include <time.h>
static const char *TAG = "MCS_Telemetry";
static bool s_telemetry_running = false;
static mcs_telemetry_cb_t s_user_callback = NULL;
static mcs_telemetry_stats_t s_stats = {0};
// 802.11ax MCS rates for 20MHz, 800ns GI, 1 Spatial Stream
// MCS 0-11: Standard HE rates
static const uint32_t PHY_RATES_20MHZ_1SS_800NS[] = {
8650, // MCS 0: BPSK 1/2
17200, // MCS 1: QPSK 1/2
25800, // MCS 2: QPSK 3/4
34400, // MCS 3: 16-QAM 1/2
51600, // MCS 4: 16-QAM 3/4
68800, // MCS 5: 64-QAM 2/3
77400, // MCS 6: 64-QAM 3/4
86000, // MCS 7: 64-QAM 5/6
103200, // MCS 8: 256-QAM 3/4
114700, // MCS 9: 256-QAM 5/6
129000, // MCS 10: 1024-QAM 3/4
143400 // MCS 11: 1024-QAM 5/6
};
// 802.11ax MCS rates for 20MHz, 400ns GI, 1 Spatial Stream
static const uint32_t PHY_RATES_20MHZ_1SS_400NS[] = {
9600, // MCS 0
19200, // MCS 1
28800, // MCS 2
38400, // MCS 3
57600, // MCS 4
76800, // MCS 5
86400, // MCS 6
96000, // MCS 7
115200, // MCS 8
128100, // MCS 9
144100, // MCS 10
160200 // MCS 11
};
// 802.11ax MCS rates for 40MHz, 800ns GI, 1 Spatial Stream
static const uint32_t PHY_RATES_40MHZ_1SS_800NS[] = {
17200, // MCS 0
34400, // MCS 1
51600, // MCS 2
68800, // MCS 3
103200, // MCS 4
137600, // MCS 5
154900, // MCS 6
172100, // MCS 7
206500, // MCS 8
229400, // MCS 9
258100, // MCS 10
286800 // MCS 11
};
// 802.11ax MCS rates for 40MHz, 400ns GI, 1 Spatial Stream
static const uint32_t PHY_RATES_40MHZ_1SS_400NS[] = {
19200, // MCS 0
38400, // MCS 1
57600, // MCS 2
76800, // MCS 3
115200, // MCS 4
153600, // MCS 5
172800, // MCS 6
192000, // MCS 7
230400, // MCS 8
256200, // MCS 9
288200, // MCS 10
320300 // MCS 11
};
/**
* @brief Get device index by MAC address, or create new entry
*/
static int mcs_get_device_index(const uint8_t *mac) {
int empty_slot = -1;
for (int i = 0; i < MCS_TELEMETRY_MAX_DEVICES; i++) {
if (memcmp(s_stats.devices[i].mac, mac, 6) == 0) {
return i; // Found existing device
}
if (empty_slot < 0 && s_stats.devices[i].sample_count == 0) {
empty_slot = i; // Found empty slot
}
}
// Use empty slot or create new entry
if (empty_slot >= 0) {
memcpy(s_stats.devices[empty_slot].mac, mac, 6);
memset(&s_stats.devices[empty_slot], 0, sizeof(mcs_device_telemetry_t));
memcpy(s_stats.devices[empty_slot].mac, mac, 6);
s_stats.total_devices++;
return empty_slot;
}
// No space - return oldest device (simple round-robin replacement)
return 0;
}
/**
* @brief Update device telemetry with new sample
*/
static void mcs_update_device_telemetry(int dev_idx, const mcs_sample_t *sample) {
if (dev_idx < 0 || dev_idx >= MCS_TELEMETRY_MAX_DEVICES) {
return;
}
mcs_device_telemetry_t *dev = &s_stats.devices[dev_idx];
// Update sample buffer (sliding window)
dev->samples[dev->sample_idx] = *sample;
dev->sample_idx = (dev->sample_idx + 1) % 16;
// Update counters
dev->sample_count++;
dev->total_frames++;
dev->total_bytes += sample->frame_len;
dev->last_update_ms = sample->timestamp_ms;
if (sample->is_retry) {
dev->retry_frames++;
}
// Update MCS distribution
if (sample->mcs <= MCS_TELEMETRY_MAX_MCS) {
dev->mcs_count[sample->mcs]++;
// Update dominant MCS (most frequent in recent samples)
uint32_t max_count = 0;
for (int i = 0; i <= MCS_TELEMETRY_MAX_MCS; i++) {
if (dev->mcs_count[i] > max_count) {
max_count = dev->mcs_count[i];
dev->current_mcs = i;
}
}
}
// Update SS distribution
if (sample->ss >= 1 && sample->ss <= MCS_TELEMETRY_MAX_SS) {
dev->ss_count[sample->ss]++;
// Update dominant SS
uint32_t max_count = 0;
for (int i = 1; i <= MCS_TELEMETRY_MAX_SS; i++) {
if (dev->ss_count[i] > max_count) {
max_count = dev->ss_count[i];
dev->current_ss = i;
}
}
}
// Update RSSI statistics
if (dev->sample_count == 1) {
dev->avg_rssi = sample->rssi;
dev->min_rssi = sample->rssi;
dev->max_rssi = sample->rssi;
} else {
// Running average
dev->avg_rssi = ((int16_t)dev->avg_rssi * (dev->sample_count - 1) + sample->rssi) / dev->sample_count;
if (sample->rssi < dev->min_rssi) dev->min_rssi = sample->rssi;
if (sample->rssi > dev->max_rssi) dev->max_rssi = sample->rssi;
}
// Update PHY rate statistics
uint32_t total_rate = dev->avg_phy_rate_kbps * (dev->sample_count - 1) + sample->phy_rate_kbps;
dev->avg_phy_rate_kbps = total_rate / dev->sample_count;
if (sample->phy_rate_kbps > dev->max_phy_rate_kbps) {
dev->max_phy_rate_kbps = sample->phy_rate_kbps;
}
}
esp_err_t mcs_telemetry_init(mcs_telemetry_cb_t callback) {
ESP_LOGI(TAG, "Initializing MCS telemetry");
s_user_callback = callback;
s_telemetry_running = false;
memset(&s_stats, 0, sizeof(mcs_telemetry_stats_t));
return ESP_OK;
}
esp_err_t mcs_telemetry_start(void) {
ESP_LOGI(TAG, "Starting MCS telemetry capture");
s_telemetry_running = true;
s_stats.window_start_ms = esp_timer_get_time() / 1000;
return ESP_OK;
}
esp_err_t mcs_telemetry_stop(void) {
ESP_LOGI(TAG, "Stopping MCS telemetry capture");
s_telemetry_running = false;
s_stats.window_end_ms = esp_timer_get_time() / 1000;
return ESP_OK;
}
esp_err_t mcs_telemetry_process_frame(const wifi_frame_info_t *frame_info, const wifi_pkt_rx_ctrl_t *rx_ctrl) {
(void)rx_ctrl; /* Optional: not used, frame_info contains RX metadata */
if (!s_telemetry_running || !frame_info) {
return ESP_ERR_INVALID_ARG;
}
// Get device index by MAC address (Addr2 = transmitter)
int dev_idx = mcs_get_device_index(frame_info->addr2);
if (dev_idx < 0) {
return ESP_ERR_NO_MEM;
}
// Create sample from frame info
mcs_sample_t sample = {0};
sample.timestamp_ms = esp_timer_get_time() / 1000;
sample.mcs = frame_info->mcs;
sample.ss = 1; // TODO: Extract from HT/VHT/HE headers
sample.rssi = frame_info->rssi;
sample.channel = frame_info->channel;
sample.bandwidth = (frame_info->bandwidth == 0) ? MCS_BW_20MHZ :
(frame_info->bandwidth == 1) ? MCS_BW_40MHZ : MCS_BW_20MHZ;
sample.frame_len = frame_info->frame_len;
sample.is_retry = frame_info->retry;
sample.sig_mode = frame_info->sig_mode;
// Calculate PHY rate if we have MCS info
if (sample.mcs <= MCS_TELEMETRY_MAX_MCS && sample.ss >= 1 && sample.ss <= MCS_TELEMETRY_MAX_SS) {
sample.phy_rate_kbps = mcs_calculate_phy_rate_ax(sample.mcs, sample.ss, sample.bandwidth, frame_info->sgi);
} else {
// Fallback to frame_info's calculated rate or estimate from rate index
sample.phy_rate_kbps = frame_info->phy_rate_kbps;
}
// Update device telemetry
mcs_update_device_telemetry(dev_idx, &sample);
s_stats.total_frames_captured++;
// TODO: Parse HT/VHT/HE headers to extract actual SS count
// This requires parsing the PLCP/HT Control/VHT Control/HE Control fields
// which follow the MAC header in HT/VHT/HE frames
return ESP_OK;
}
esp_err_t mcs_telemetry_get_stats(mcs_telemetry_stats_t *stats) {
if (!stats) {
return ESP_ERR_INVALID_ARG;
}
memcpy(stats, &s_stats, sizeof(mcs_telemetry_stats_t));
return ESP_OK;
}
void mcs_telemetry_reset(void) {
ESP_LOGI(TAG, "Resetting telemetry statistics");
memset(&s_stats, 0, sizeof(mcs_telemetry_stats_t));
}
esp_err_t mcs_telemetry_to_json(char *json_buffer, size_t buffer_len, const char *device_id) {
if (!json_buffer || buffer_len == 0) {
return ESP_ERR_INVALID_ARG;
}
uint32_t now_ms = esp_timer_get_time() / 1000;
int written = snprintf(json_buffer, buffer_len,
"{\"device_id\":\"%s\",\"timestamp\":%lu,\"total_frames\":%lu,\"devices\":[",
device_id ? device_id : "unknown", now_ms, s_stats.total_frames_captured);
if (written < 0 || written >= buffer_len) {
return ESP_ERR_NO_MEM;
}
int offset = written;
bool first = true;
for (int i = 0; i < MCS_TELEMETRY_MAX_DEVICES && offset < buffer_len - 100; i++) {
mcs_device_telemetry_t *dev = &s_stats.devices[i];
if (dev->sample_count == 0) continue;
if (!first) {
written = snprintf(json_buffer + offset, buffer_len - offset, ",");
if (written < 0) break;
offset += written;
}
first = false;
written = snprintf(json_buffer + offset, buffer_len - offset,
"{\"mac\":\"%02x:%02x:%02x:%02x:%02x:%02x\","
"\"mcs\":%u,\"ss\":%u,\"rssi\":%d,"
"\"channel\":%u,\"bandwidth\":%u,"
"\"frames\":%lu,\"retries\":%lu,"
"\"phy_rate_kbps\":%lu}",
dev->mac[0], dev->mac[1], dev->mac[2], dev->mac[3], dev->mac[4], dev->mac[5],
dev->current_mcs, dev->current_ss, dev->avg_rssi,
dev->samples[0].channel, dev->samples[0].bandwidth,
dev->total_frames, dev->retry_frames,
dev->avg_phy_rate_kbps);
if (written < 0) break;
offset += written;
}
written = snprintf(json_buffer + offset, buffer_len - offset, "]}");
if (written < 0) {
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}
uint32_t mcs_calculate_phy_rate_ax(uint8_t mcs, uint8_t ss, mcs_bandwidth_t bandwidth, bool sgi) {
if (mcs > MCS_TELEMETRY_MAX_MCS || ss < 1 || ss > MCS_TELEMETRY_MAX_SS) {
return 0;
}
const uint32_t *rate_table = NULL;
if (bandwidth == MCS_BW_20MHZ) {
rate_table = sgi ? PHY_RATES_20MHZ_1SS_400NS : PHY_RATES_20MHZ_1SS_800NS;
} else if (bandwidth == MCS_BW_40MHZ) {
rate_table = sgi ? PHY_RATES_40MHZ_1SS_400NS : PHY_RATES_40MHZ_1SS_800NS;
} else {
return 0;
}
// PHY rate = base rate (1SS) * spatial streams
uint32_t base_rate = rate_table[mcs];
return base_rate * ss;
}

View File

@ -0,0 +1,211 @@
/*
* mcs_telemetry.h
*
* Copyright (c) 2026 Umber Networks & Robert McMahon
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
#ifndef MCS_TELEMETRY_H
#define MCS_TELEMETRY_H
#include "esp_wifi.h"
#include "esp_wifi_types.h"
#include <stdbool.h>
#include <stdint.h>
// Include wifi_monitor.h to get wifi_frame_info_t definition
#include "wifi_monitor.h"
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Maximum MCS index for 802.11ax (0-11)
*/
#define MCS_TELEMETRY_MAX_MCS 11
/**
* @brief Maximum Spatial Streams (1-4 for ESP32-C5)
*/
#define MCS_TELEMETRY_MAX_SS 4
/**
* @brief Maximum device entries to track
*/
#define MCS_TELEMETRY_MAX_DEVICES 16
/**
* @brief Telemetry aggregation window in milliseconds
*/
#define MCS_TELEMETRY_WINDOW_MS 1000
/**
* @brief 802.11ax Bandwidth types (ESP32-C5 supports 20MHz and 40MHz)
*/
typedef enum {
MCS_BW_20MHZ = 0,
MCS_BW_40MHZ = 1
} mcs_bandwidth_t;
/**
* @brief Single telemetry sample
*/
typedef struct {
uint32_t timestamp_ms; // Timestamp in milliseconds
uint8_t mcs; // MCS index (0-11)
uint8_t ss; // Spatial Streams (1-4)
int8_t rssi; // RSSI in dBm
uint8_t channel; // WiFi channel
mcs_bandwidth_t bandwidth; // Bandwidth (20MHz or 40MHz)
uint32_t phy_rate_kbps; // PHY rate in Kbps
uint16_t frame_len; // Frame length in bytes
bool is_retry; // Retry flag
uint8_t sig_mode; // Signal mode (0=legacy, 1=HT, 3=VHT, 4=HE)
} mcs_sample_t;
/**
* @brief Aggregated telemetry per device (MAC address)
*/
typedef struct {
uint8_t mac[6]; // MAC address of the device
uint32_t sample_count; // Number of samples in this window
uint32_t last_update_ms; // Last update timestamp
// Aggregated statistics
uint8_t current_mcs; // Current/dominant MCS
uint8_t current_ss; // Current/dominant SS
int8_t avg_rssi; // Average RSSI
int8_t min_rssi; // Minimum RSSI
int8_t max_rssi; // Maximum RSSI
uint32_t total_bytes; // Total bytes transmitted
uint32_t total_frames; // Total frame count
uint32_t retry_frames; // Retry frame count
uint32_t avg_phy_rate_kbps; // Average PHY rate
uint32_t max_phy_rate_kbps; // Maximum PHY rate
// MCS distribution (how many frames per MCS)
uint32_t mcs_count[MCS_TELEMETRY_MAX_MCS + 1];
// SS distribution (how many frames per SS)
uint32_t ss_count[MCS_TELEMETRY_MAX_SS + 1];
// Time series for recent samples (sliding window)
mcs_sample_t samples[16]; // Last 16 samples
uint8_t sample_idx; // Current sample index
} mcs_device_telemetry_t;
/**
* @brief Global telemetry statistics
*/
typedef struct {
uint32_t total_frames_captured;
uint32_t total_devices;
uint32_t window_start_ms;
uint32_t window_end_ms;
mcs_device_telemetry_t devices[MCS_TELEMETRY_MAX_DEVICES];
} mcs_telemetry_stats_t;
/**
* @brief Callback function type for telemetry updates
*
* @param stats Telemetry statistics
*/
typedef void (*mcs_telemetry_cb_t)(const mcs_telemetry_stats_t *stats);
/**
* @brief Initialize MCS telemetry capture
*
* @param callback Optional callback for telemetry updates (can be NULL)
* @return esp_err_t ESP_OK on success
*/
esp_err_t mcs_telemetry_init(mcs_telemetry_cb_t callback);
/**
* @brief Start MCS telemetry capture
*
* @return esp_err_t ESP_OK on success
*/
esp_err_t mcs_telemetry_start(void);
/**
* @brief Stop MCS telemetry capture
*
* @return esp_err_t ESP_OK on success
*/
esp_err_t mcs_telemetry_stop(void);
/**
* @brief Process a captured 802.11 frame
*
* @param frame_info Parsed frame information (from wifi_monitor)
* @param rx_ctrl RX control information
* @return esp_err_t ESP_OK on success
*/
esp_err_t mcs_telemetry_process_frame(const wifi_frame_info_t *frame_info, const wifi_pkt_rx_ctrl_t *rx_ctrl);
/**
* @brief Get current telemetry statistics
*
* @param stats Output: telemetry statistics
* @return esp_err_t ESP_OK on success
*/
esp_err_t mcs_telemetry_get_stats(mcs_telemetry_stats_t *stats);
/**
* @brief Reset telemetry statistics
*/
void mcs_telemetry_reset(void);
/**
* @brief Get telemetry as JSON string (for HTTP POST)
*
* @param json_buffer Output buffer for JSON string
* @param buffer_len Buffer length
* @param device_id Device identifier string
* @return esp_err_t ESP_OK on success
*/
esp_err_t mcs_telemetry_to_json(char *json_buffer, size_t buffer_len, const char *device_id);
/**
* @brief Calculate PHY rate from MCS, SS, and bandwidth (802.11ax)
*
* @param mcs MCS index (0-11)
* @param ss Spatial Streams (1-4)
* @param bandwidth Bandwidth (20MHz or 40MHz)
* @param sgi Short Guard Interval (true = 400ns, false = 800ns)
* @return uint32_t PHY rate in Kbps, 0 if invalid
*/
uint32_t mcs_calculate_phy_rate_ax(uint8_t mcs, uint8_t ss, mcs_bandwidth_t bandwidth, bool sgi);
#ifdef __cplusplus
}
#endif
#endif // MCS_TELEMETRY_H

View File

@ -300,6 +300,51 @@ esp_err_t sd_card_read_file(const char *filename, void *data, size_t len, size_t
return ESP_OK;
}
esp_err_t sd_card_get_file_size(const char *filename, size_t *size_bytes) {
if (!s_sd_card_mounted || size_bytes == NULL) {
return ESP_ERR_INVALID_STATE;
}
char full_path[128];
snprintf(full_path, sizeof(full_path), "%s%s%s", s_mount_point,
(filename[0] == '/') ? "" : "/", filename);
struct stat st;
if (stat(full_path, &st) != 0) {
return ESP_FAIL;
}
if (!S_ISREG(st.st_mode)) {
return ESP_ERR_INVALID_ARG;
}
*size_bytes = (size_t)st.st_size;
return ESP_OK;
}
esp_err_t sd_card_read_file_at(const char *filename, size_t offset, void *data, size_t len, size_t *bytes_read) {
if (!s_sd_card_mounted) {
return ESP_ERR_INVALID_STATE;
}
char full_path[128];
snprintf(full_path, sizeof(full_path), "%s%s%s", s_mount_point,
(filename[0] == '/') ? "" : "/", filename);
FILE *f = fopen(full_path, "rb");
if (f == NULL) {
return ESP_FAIL;
}
if (fseek(f, (long)offset, SEEK_SET) != 0) {
fclose(f);
return ESP_FAIL;
}
size_t n = fread(data, 1, len, f);
fclose(f);
if (bytes_read) {
*bytes_read = n;
}
return ESP_OK;
}
bool sd_card_file_exists(const char *filename) {
if (!s_sd_card_mounted) {
return false;
@ -313,6 +358,49 @@ bool sd_card_file_exists(const char *filename) {
return (stat(full_path, &st) == 0);
}
esp_err_t sd_card_list_dir(const char *path) {
if (!s_sd_card_mounted) {
return ESP_ERR_INVALID_STATE;
}
char full_path[128];
if (!path || path[0] == '\0') {
snprintf(full_path, sizeof(full_path), "%s", s_mount_point);
} else {
snprintf(full_path, sizeof(full_path), "%s/%s", s_mount_point,
(path[0] == '/') ? path + 1 : path);
}
DIR *d = opendir(full_path);
if (!d) {
return ESP_FAIL;
}
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (e->d_name[0] == '.') {
continue;
}
char entry_path[384]; /* full_path(128) + "/" + d_name(255) */
int n = snprintf(entry_path, sizeof(entry_path), "%s/%s", full_path, e->d_name);
if (n < 0 || n >= (int)sizeof(entry_path)) {
continue; /* path too long, skip */
}
struct stat st;
if (stat(entry_path, &st) == 0) {
if (S_ISDIR(st.st_mode)) {
printf(" %-32s <DIR>\n", e->d_name);
} else {
printf(" %-32s %10zu bytes\n", e->d_name, (size_t)st.st_size);
}
} else {
printf(" %-32s ?\n", e->d_name);
}
}
closedir(d);
return ESP_OK;
}
esp_err_t sd_card_delete_file(const char *filename) {
if (!s_sd_card_mounted) {
return ESP_ERR_INVALID_STATE;

View File

@ -110,6 +110,27 @@ esp_err_t sd_card_write_file(const char *filename, const void *data, size_t len,
*/
esp_err_t sd_card_read_file(const char *filename, void *data, size_t len, size_t *bytes_read);
/**
* @brief Get size of a file in bytes
*
* @param filename File path
* @param size_bytes Output parameter for file size
* @return ESP_OK on success
*/
esp_err_t sd_card_get_file_size(const char *filename, size_t *size_bytes);
/**
* @brief Read a chunk of a file at given offset (for streaming)
*
* @param filename File path
* @param offset Byte offset from start
* @param data Buffer to read into
* @param len Maximum length to read
* @param bytes_read Output parameter for actual bytes read
* @return ESP_OK on success
*/
esp_err_t sd_card_read_file_at(const char *filename, size_t offset, void *data, size_t len, size_t *bytes_read);
/**
* @brief Check if a file exists on the SD card
*
@ -118,6 +139,14 @@ esp_err_t sd_card_read_file(const char *filename, void *data, size_t len, size_t
*/
bool sd_card_file_exists(const char *filename);
/**
* @brief List files in a directory on the SD card
*
* @param path Directory path (empty or "/" for root)
* @return ESP_OK on success
*/
esp_err_t sd_card_list_dir(const char *path);
/**
* @brief Delete a file from the SD card
*

View File

@ -0,0 +1,5 @@
idf_component_register(
SRCS "sdcard_http.c"
INCLUDE_DIRS "."
REQUIRES sd_card esp_http_server
)

View File

@ -0,0 +1,265 @@
/*
* sdcard_http.c
*
* Copyright (c) 2025 Umber Networks & Robert McMahon
* SPDX-License-Identifier: BSD-3-Clause
*
* Serves files from the SD card via HTTP GET /sdcard/<path>.
* Use: wget http://<device-ip>:8080/sdcard/myfile.txt
*
* Telemetry download stats are persisted to telemetry-status on the SD card
* and survive reboots. Format: attempts=N, downloads=M, and per-download history
* with timestamp and bytes.
*/
#include "sdcard_http.h"
#include "sd_card.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "sdkconfig.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <inttypes.h>
#include <sys/stat.h>
#include <time.h>
static const char *TAG = "sdcard_http";
static httpd_handle_t s_server = NULL;
static uint32_t s_telemetry_attempts = 0;
static uint32_t s_telemetry_downloads = 0;
#define SDCARD_HTTP_CHUNK 2048
#define TELEMETRY_FILE "fiwi-telemetry"
#define TELEMETRY_STATUS_FILE "telemetry-status"
#define SDCARD_URI_PREFIX "/sdcard"
#define STATUS_MAX_SIZE 4096
#define MAX_HISTORY_LINES 50
#if !defined(CONFIG_TELEMETRY_AUTO_DELETE_AFTER_DOWNLOAD)
#define CONFIG_TELEMETRY_AUTO_DELETE_AFTER_DOWNLOAD 0
#endif
/* Reject path if it contains ".." to avoid traversal */
static bool path_is_safe(const char *path) {
if (!path || path[0] == '\0') return false;
const char *p = path;
while (*p) {
if (p[0] == '.' && p[1] == '.') return false;
p++;
}
return true;
}
/* Load telemetry status from SD card. Format:
* attempts=N
* downloads=M
* ---
* timestamp bytes
* ...
*/
static void load_telemetry_status(void) {
if (!sd_card_is_ready()) return;
if (!sd_card_file_exists(TELEMETRY_STATUS_FILE)) return;
static char buf[STATUS_MAX_SIZE];
size_t n = 0;
if (sd_card_read_file(TELEMETRY_STATUS_FILE, buf, sizeof(buf) - 1, &n) != ESP_OK || n == 0) return;
buf[n] = '\0';
char *p = buf;
while (*p) {
if (strncmp(p, "attempts=", 9) == 0) {
s_telemetry_attempts = (uint32_t)strtoul(p + 9, NULL, 10);
} else if (strncmp(p, "downloads=", 10) == 0) {
s_telemetry_downloads = (uint32_t)strtoul(p + 10, NULL, 10);
} else if (strncmp(p, "---", 3) == 0) {
break; /* rest is history, ignore for counts */
}
p = strchr(p, '\n');
if (!p) break;
p++;
}
}
/* Save telemetry status: attempts, downloads, and history (timestamp bytes per line).
* Appends new entry and trims history to MAX_HISTORY_LINES.
*/
static void save_telemetry_status(size_t bytes_sent) {
if (!sd_card_is_ready()) return;
time_t ts = time(NULL);
if (ts < 0) ts = 0;
/* Read existing file to get history */
static char buf[STATUS_MAX_SIZE];
char *history_start = NULL;
size_t history_count = 0;
if (sd_card_file_exists(TELEMETRY_STATUS_FILE)) {
size_t n = 0;
if (sd_card_read_file(TELEMETRY_STATUS_FILE, buf, sizeof(buf) - 1, &n) == ESP_OK && n > 0) {
buf[n] = '\0';
history_start = strstr(buf, "---\n");
if (history_start) {
history_start += 4;
char *line = history_start;
while (*line && history_count < MAX_HISTORY_LINES) {
if (line[0] && line[0] != '\n') history_count++;
line = strchr(line, '\n');
if (!line) break;
line++;
}
}
}
}
/* Build new content: header + trimmed history + new entry */
static char out[STATUS_MAX_SIZE];
int len = snprintf(out, sizeof(out), "attempts=%" PRIu32 "\ndownloads=%" PRIu32 "\n---\n",
s_telemetry_attempts, s_telemetry_downloads);
if (len < 0 || len >= (int)sizeof(out)) return;
/* Append existing history (skip oldest if we're at max) */
if (history_start && history_count > 0) {
char *line = history_start;
size_t skip = (history_count >= MAX_HISTORY_LINES) ? 1 : 0;
size_t kept = 0;
while (*line && len < (int)sizeof(out) - 64) {
if (line[0] && line[0] != '\n') {
if (skip > 0) {
skip--;
} else {
char *end = strchr(line, '\n');
size_t line_len = end ? (size_t)(end - line) + 1 : strlen(line);
if (len + line_len >= sizeof(out)) break;
memcpy(out + len, line, line_len);
len += (int)line_len;
kept++;
if (kept >= MAX_HISTORY_LINES - 1) break;
}
}
line = strchr(line, '\n');
if (!line) break;
line++;
}
}
/* Append new entry */
int n = snprintf(out + len, sizeof(out) - (size_t)len, "%ld %zu\n", (long)ts, bytes_sent);
if (n > 0) len += n;
sd_card_write_file(TELEMETRY_STATUS_FILE, out, (size_t)len, false);
}
static esp_err_t sdcard_file_handler(httpd_req_t *req) {
if (!sd_card_is_ready()) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "SD card not mounted");
return ESP_OK;
}
/* URI is /sdcard/foo/bar -> path = foo/bar */
const char *uri = req->uri;
if (strncmp(uri, SDCARD_URI_PREFIX, strlen(SDCARD_URI_PREFIX)) != 0) {
return ESP_FAIL;
}
const char *path = uri + strlen(SDCARD_URI_PREFIX);
if (path[0] == '/') path++;
if (path[0] == '\0') {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing path");
return ESP_OK;
}
if (!path_is_safe(path)) {
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "Invalid path");
return ESP_OK;
}
bool is_telemetry = (strcmp(path, TELEMETRY_FILE) == 0);
if (is_telemetry) {
s_telemetry_attempts++;
}
size_t file_size = 0;
if (sd_card_get_file_size(path, &file_size) != ESP_OK) {
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File not found");
return ESP_OK;
}
httpd_resp_set_type(req, "application/octet-stream");
httpd_resp_set_hdr(req, "Content-Disposition", "attachment");
static uint8_t buf[SDCARD_HTTP_CHUNK];
size_t offset = 0;
while (offset < file_size) {
size_t to_read = file_size - offset;
if (to_read > sizeof(buf)) to_read = sizeof(buf);
size_t n = 0;
if (sd_card_read_file_at(path, offset, buf, to_read, &n) != ESP_OK || n == 0) {
break;
}
if (httpd_resp_send_chunk(req, (char *)buf, n) != ESP_OK) {
return ESP_FAIL;
}
offset += n;
}
if (httpd_resp_send_chunk(req, NULL, 0) != ESP_OK) {
return ESP_FAIL;
}
if (is_telemetry) {
s_telemetry_downloads++;
save_telemetry_status(file_size);
if (CONFIG_TELEMETRY_AUTO_DELETE_AFTER_DOWNLOAD) {
sd_card_delete_file(TELEMETRY_FILE);
}
}
return ESP_OK;
}
esp_err_t sdcard_http_start(void) {
if (s_server != NULL) {
return ESP_OK;
}
load_telemetry_status();
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = 8080;
config.max_uri_handlers = 8;
config.max_open_sockets = 4;
config.uri_match_fn = httpd_uri_match_wildcard;
if (httpd_start(&s_server, &config) != ESP_OK) {
ESP_LOGE(TAG, "Failed to start HTTP server");
return ESP_FAIL;
}
httpd_uri_t sdcard_uri = {
.uri = "/sdcard/*",
.method = HTTP_GET,
.handler = sdcard_file_handler,
.user_ctx = NULL,
};
if (httpd_register_uri_handler(s_server, &sdcard_uri) != ESP_OK) {
httpd_stop(s_server);
s_server = NULL;
ESP_LOGE(TAG, "Failed to register /sdcard/* handler");
return ESP_FAIL;
}
ESP_LOGI(TAG, "HTTP server on port 8080: GET http://<ip>:8080/sdcard/<path>");
return ESP_OK;
}
void sdcard_http_get_telemetry_stats(uint32_t *attempts, uint32_t *downloads) {
if (attempts) *attempts = s_telemetry_attempts;
if (downloads) *downloads = s_telemetry_downloads;
}
void sdcard_http_stop(void) {
if (s_server) {
httpd_stop(s_server);
s_server = NULL;
ESP_LOGI(TAG, "HTTP server stopped");
}
}

View File

@ -0,0 +1,32 @@
/*
* sdcard_http.h
*
* Copyright (c) 2025 Umber Networks & Robert McMahon
* SPDX-License-Identifier: BSD-3-Clause
*/
#ifndef SDCARD_HTTP_H
#define SDCARD_HTTP_H
#include "esp_err.h"
/**
* @brief Start HTTP server that serves files from SD card at GET /sdcard/<path>
* Listens on port 8080. Call once after WiFi and SD card init.
* @return ESP_OK on success
*/
esp_err_t sdcard_http_start(void);
/**
* @brief Stop the SD card HTTP server (optional)
*/
void sdcard_http_stop(void);
/**
* @brief Get HTTP download stats for fiwi-telemetry
* @param attempts Output: total download attempts (may be NULL)
* @param downloads Output: successful downloads (may be NULL)
*/
void sdcard_http_get_telemetry_stats(uint32_t *attempts, uint32_t *downloads);
#endif /* SDCARD_HTTP_H */

View File

@ -1,4 +1,5 @@
idf_component_register(SRCS "wifi_controller.c"
INCLUDE_DIRS "."
REQUIRES esp_wifi freertos
PRIV_REQUIRES csi_manager iperf status_led wifi_monitor wifi_cfg gps_sync log esp_netif)
PRIV_REQUIRES csi_manager iperf status_led wifi_monitor wifi_cfg gps_sync log esp_netif
mcs_telemetry sd_card)

View File

@ -38,6 +38,7 @@
#include "esp_event.h"
#include "esp_netif.h"
#include "inttypes.h"
#include <string.h>
#include "wifi_cfg.h"
// Dependencies
@ -50,6 +51,12 @@
#include "csi_manager.h"
#endif
#include "mcs_telemetry.h"
#include "sd_card.h"
#define FIWI_TELEMETRY_FILE "fiwi-telemetry"
#define FIWI_TELEMETRY_JSON_BUF_SIZE 4096
static const char *TAG = "WIFI_CTL";
static wifi_ctl_mode_t s_current_mode = WIFI_CTL_MODE_STA;
@ -81,13 +88,20 @@ static void log_collapse_event(uint32_t nav_duration_us, int rssi, int retry) {
}
static void monitor_frame_callback(const wifi_frame_info_t *frame, const uint8_t *payload, uint16_t len) {
(void)payload;
(void)len;
s_monitor_frame_count++;
if (frame->retry && frame->duration_id > 5000) {
log_collapse_event((float)frame->duration_id, frame->rssi, frame->retry);
}
/* MCS telemetry: feed frames to fiwi-telemetry (default on monitor start) */
mcs_telemetry_process_frame(frame, NULL);
}
static void monitor_stats_task(void *arg) {
(void)arg;
static char json_buf[FIWI_TELEMETRY_JSON_BUF_SIZE];
uint32_t flush_count = 0;
while (1) {
vTaskDelay(pdMS_TO_TICKS(10000));
wifi_collapse_stats_t stats;
@ -96,6 +110,14 @@ static void monitor_stats_task(void *arg) {
(unsigned long)stats.total_frames, stats.retry_rate, stats.avg_nav);
if (wifi_monitor_is_collapsed()) ESP_LOGW("MONITOR", "⚠️ COLLAPSE DETECTED! ⚠️");
}
/* Write MCS telemetry to fiwi-telemetry on SD card (default on monitor start) */
if (sd_card_is_ready() && mcs_telemetry_to_json(json_buf, sizeof(json_buf), "esp32") == ESP_OK) {
size_t len = strlen(json_buf);
if (len > 0 && sd_card_write_file(FIWI_TELEMETRY_FILE, json_buf, len, false) == ESP_OK) {
flush_count++;
ESP_LOGD(TAG, "fiwi-telemetry flushed (#%lu)", (unsigned long)flush_count);
}
}
}
}
@ -195,6 +217,13 @@ esp_err_t wifi_ctl_switch_to_monitor(uint8_t channel, wifi_bandwidth_t bw) {
return ESP_FAIL;
}
/* MCS telemetry -> fiwi-telemetry on SD (default on monitor start) */
if (mcs_telemetry_init(NULL) != ESP_OK) {
ESP_LOGW(TAG, "MCS telemetry init failed");
} else if (mcs_telemetry_start() != ESP_OK) {
ESP_LOGW(TAG, "MCS telemetry start failed");
}
esp_wifi_set_bandwidth(WIFI_IF_STA, bw);
if (wifi_monitor_start() != ESP_OK) {
@ -228,6 +257,7 @@ esp_err_t wifi_ctl_switch_to_sta(void) {
}
if (s_monitor_enabled) {
mcs_telemetry_stop();
wifi_monitor_stop();
s_monitor_enabled = false;
vTaskDelay(pdMS_TO_TICKS(500));

View File

@ -157,6 +157,96 @@ If the card is not detected, check:
3. Card is formatted (FAT32)
4. Power supply is stable (3.3V)
## Downloading files from the SD card
You can copy files off the SD card in two ways:
### Over WiFi (HTTP)
When the device is on a network, an HTTP server listens on port **8080**. Request a file with:
- **URL:** `http://<device-ip>:8080/sdcard/<path>`
- Example: `http://192.168.1.100:8080/sdcard/log/data.txt`
Use a browser or `curl`/`wget` to download. Paths are relative to the SD root; `..` is not allowed.
### Over serial (USB)
From the device console, run:
```text
sdcard send <filename>
```
The firmware streams the file in a hex-encoded protocol. On the host, use the provided script (requires Python 3 and `pyserial`):
```bash
pip install pyserial
python3 tools/sdcard_recv.py -p /dev/ttyUSB0 -f myfile.txt -o saved.txt
```
- `-p` / `--port`: serial port
- `-f` / `--remote`: path of the file on the SD card
- `-o` / `--output`: local path to save (default: basename of remote file)
Serial transfer is limited to 512 KB per file.
## Device discovery (broadcast beacon)
When connected to WiFi, the device sends periodic UDP broadcast packets on port **5555** so a laptop on the same LAN can discover it. The payload is JSON:
```json
{"ip":"192.168.1.73","mask":"255.255.255.0","gw":"192.168.1.1","dhcp":"OFF","mac":"3c:dc:75:82:2a:a8","fiwi_telemetry":true}
```
- **mac**: Unique identifier per device (use for deduplication when many devices are present).
- **fiwi_telemetry**: `true` if file `fiwi-telemetry` exists on the SD card, `false` otherwise.
Beacon interval is configurable in `idf.py menuconfig`**ESP32 iperf Configuration****Device discovery****Broadcast beacon interval** (default 5 seconds).
On a Linux laptop:
**Listen only (no download):**
```bash
python3 tools/beacon_listen.py
```
**Listen and download fiwi-telemetry from each device** (files saved as `fiwi-telemetry_<mac>` in `./telemetry/`):
```bash
python3 tools/beacon_listen.py --download --output-dir ./telemetry
```
**Re-download every 60 seconds:**
```bash
python3 tools/beacon_listen.py --download --output-dir ./telemetry --refresh 60
```
Devices are tracked by MAC address; each telemetry file is uniquely named.
## Telemetry status file
Download stats (attempts, successful downloads) are written to `telemetry-status` on the SD card and persist across reboots. The file format:
```
attempts=10
downloads=8
---
1738857600 1234
1738857701 1235
```
Lines after `---` are `timestamp bytes` (Unix epoch and bytes downloaded). View with:
```text
sdcard read telemetry-status
```
**Auto-delete option:** In `idf.py menuconfig`**ESP32 iperf Configuration****Telemetry** → enable **Auto-delete fiwi-telemetry file after successful HTTP download** to remove the telemetry file after each successful download.
## fiwi-telemetry: monitor mode default
When you run **monitor start**, the firmware automatically writes MCS telemetry (frame rates, MCS indices, RSSI, PHY rates per device) to the `fiwi-telemetry` file on the SD card. The file is updated every 10 seconds while monitor mode is active. No extra configuration is needed—telemetry logging to `fiwi-telemetry` is the default behavior when monitor mode is running.
## Troubleshooting
**Card not detected:**

View File

@ -1,3 +1,3 @@
idf_component_register(SRCS "main.c"
idf_component_register(SRCS "main.c" "broadcast_beacon.c"
INCLUDE_DIRS "."
PRIV_REQUIRES nvs_flash esp_netif wifi_controller wifi_cfg app_console iperf status_led gps_sync console sd_card)
PRIV_REQUIRES nvs_flash esp_netif wifi_controller wifi_cfg app_console iperf status_led gps_sync console sd_card sdcard_http)

View File

@ -45,6 +45,24 @@ menu "ESP32 iperf Configuration"
help
Netmask for the network.
menu "Device discovery (broadcast beacon)"
config BEACON_INTERVAL_SEC
int "Broadcast beacon interval (seconds)"
default 5
range 1 60
help
Interval in seconds between UDP broadcast beacons for laptop discovery.
endmenu
menu "Telemetry (fiwi-telemetry)"
config TELEMETRY_AUTO_DELETE_AFTER_DOWNLOAD
bool "Auto-delete fiwi-telemetry file after successful HTTP download"
default n
help
When enabled, the fiwi-telemetry file is automatically deleted
from the SD card after it has been successfully downloaded via HTTP.
endmenu
menu "SD Card"
config SD_CD_GPIO
int "Card Detect GPIO (-1 to disable)"

175
main/broadcast_beacon.c Normal file
View File

@ -0,0 +1,175 @@
/*
* broadcast_beacon.c
*
* Copyright (c) 2025 Umber Networks & Robert McMahon
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted under the terms of the BSD 3-Clause License.
*
* Sends periodic UDP broadcast so a Linux laptop can detect the device.
* Advertises: IP, mask, gw, dhcp, MAC, and whether fiwi-telemetry file exists on SD.
*/
#include <string.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "esp_log.h"
#include "esp_event.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "sd_card.h"
#include "sdkconfig.h"
#define BEACON_PORT 5555
#ifndef CONFIG_BEACON_INTERVAL_SEC
#define CONFIG_BEACON_INTERVAL_SEC 5
#endif
#define BEACON_PAYLOAD_MAX 256
static const char *TAG = "BEACON";
static esp_event_handler_instance_t s_instance_got_ip = NULL;
static esp_event_handler_instance_t s_instance_disconnected = NULL;
static TaskHandle_t s_beacon_task = NULL;
static EventGroupHandle_t s_beacon_events = NULL;
#define BEACON_IP_READY_BIT (1 << 0)
#define BEACON_STOP_BIT (1 << 1)
static void beacon_task(void *arg) {
(void)arg;
int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (sock < 0) {
ESP_LOGE(TAG, "socket: %s", strerror(errno));
vTaskDelete(NULL);
return;
}
int enable = 1;
if (setsockopt(sock, SOL_SOCKET, SO_BROADCAST, &enable, sizeof(enable)) != 0) {
ESP_LOGW(TAG, "setsockopt SO_BROADCAST: %s", strerror(errno));
}
struct sockaddr_in dst = {0};
dst.sin_family = AF_INET;
dst.sin_port = htons(BEACON_PORT);
dst.sin_addr.s_addr = htonl(INADDR_BROADCAST);
esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (!netif) {
ESP_LOGE(TAG, "no WIFI_STA netif");
close(sock);
vTaskDelete(NULL);
return;
}
char payload[BEACON_PAYLOAD_MAX];
while (1) {
EventBits_t bits = xEventGroupWaitBits(s_beacon_events,
BEACON_STOP_BIT, pdTRUE, pdFALSE,
pdMS_TO_TICKS(CONFIG_BEACON_INTERVAL_SEC * 1000));
if (bits & BEACON_STOP_BIT) {
break;
}
esp_netif_ip_info_t ip_info;
if (esp_netif_get_ip_info(netif, &ip_info) != ESP_OK || ip_info.ip.addr == 0) {
continue;
}
esp_netif_dhcp_status_t dhcp_status;
esp_netif_dhcpc_get_status(netif, &dhcp_status);
bool dhcp_on = (dhcp_status == ESP_NETIF_DHCP_STARTED);
uint8_t mac[6] = {0};
esp_netif_get_mac(netif, mac);
bool fiwi_telemetry = sd_card_is_ready() && sd_card_file_exists("fiwi-telemetry");
int n = snprintf(payload, sizeof(payload),
"{\"ip\":\"" IPSTR "\",\"mask\":\"" IPSTR "\",\"gw\":\"" IPSTR "\","
"\"dhcp\":\"%s\",\"mac\":\"%02x:%02x:%02x:%02x:%02x:%02x\","
"\"fiwi_telemetry\":%s}\n",
IP2STR(&ip_info.ip), IP2STR(&ip_info.netmask), IP2STR(&ip_info.gw),
dhcp_on ? "ON" : "OFF",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5],
fiwi_telemetry ? "true" : "false");
if (n < 0 || n >= (int)sizeof(payload)) {
continue;
}
ssize_t sent = sendto(sock, payload, (size_t)n, 0,
(struct sockaddr *)&dst, sizeof(dst));
if (sent < 0) {
ESP_LOGD(TAG, "sendto: %s", strerror(errno));
}
}
close(sock);
s_beacon_task = NULL;
vTaskDelete(NULL);
}
static void beacon_event_handler(void *arg, esp_event_base_t event_base,
int32_t event_id, void *event_data) {
(void)arg;
(void)event_data;
if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
xEventGroupClearBits(s_beacon_events, BEACON_STOP_BIT);
xEventGroupSetBits(s_beacon_events, BEACON_IP_READY_BIT);
if (s_beacon_task == NULL) {
xTaskCreate(beacon_task, "beacon", 4096, NULL, 5, &s_beacon_task);
}
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
xEventGroupClearBits(s_beacon_events, BEACON_IP_READY_BIT);
xEventGroupSetBits(s_beacon_events, BEACON_STOP_BIT);
/* task will exit and set s_beacon_task = NULL */
}
}
esp_err_t broadcast_beacon_init(void) {
if (s_beacon_events == NULL) {
s_beacon_events = xEventGroupCreate();
if (s_beacon_events == NULL) {
return ESP_ERR_NO_MEM;
}
}
esp_err_t err;
err = esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP,
&beacon_event_handler, NULL, &s_instance_got_ip);
if (err != ESP_OK) return err;
err = esp_event_handler_instance_register(WIFI_EVENT, WIFI_EVENT_STA_DISCONNECTED,
&beacon_event_handler, NULL, &s_instance_disconnected);
if (err != ESP_OK) {
esp_event_handler_instance_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP, s_instance_got_ip);
return err;
}
/* If already connected, start beacon immediately */
esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (netif) {
esp_netif_ip_info_t ip_info;
if (esp_netif_get_ip_info(netif, &ip_info) == ESP_OK && ip_info.ip.addr != 0) {
xEventGroupSetBits(s_beacon_events, BEACON_IP_READY_BIT);
if (s_beacon_task == NULL) {
xTaskCreate(beacon_task, "beacon", 4096, NULL, 5, &s_beacon_task);
}
}
}
ESP_LOGI(TAG, "Beacon init: UDP broadcast port %d, interval %d s", BEACON_PORT, CONFIG_BEACON_INTERVAL_SEC);
return ESP_OK;
}

28
main/broadcast_beacon.h Normal file
View File

@ -0,0 +1,28 @@
/*
* broadcast_beacon.h
*
* Copyright (c) 2025 Umber Networks & Robert McMahon
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted under the terms of the BSD 3-Clause License.
*/
#ifndef BROADCAST_BEACON_H
#define BROADCAST_BEACON_H
#include "esp_err.h"
/**
* @brief Initialize the broadcast beacon (registers event handlers).
*
* On IP_EVENT_STA_GOT_IP, starts periodic UDP broadcast of device info.
* On WIFI_EVENT_STA_DISCONNECTED, stops the beacon.
*
* Beacon payload (JSON) includes: ip, mask, gw, dhcp, mac, fiwi_telemetry.
* fiwi_telemetry is true if SD card has file "fiwi-telemetry".
* Listen on UDP port 5555 for broadcast from 255.255.255.255.
*/
esp_err_t broadcast_beacon_init(void);
#endif /* BROADCAST_BEACON_H */

View File

@ -59,6 +59,8 @@
#include "iperf.h"
#include "sd_card.h"
#include "sdcard_http.h"
#include "broadcast_beacon.h"
#ifdef CONFIG_ESP_WIFI_CSI_ENABLED
#include "csi_log.h"
#include "csi_manager.h"
@ -192,6 +194,11 @@ void app_main(void) {
ESP_LOGW(TAG, "SD card initialization failed (card may not be present): %s", esp_err_to_name(sd_ret));
}
/* Start HTTP server for SD file download (GET /sdcard/<path> on port 8080) */
if (sd_ret == ESP_OK && sdcard_http_start() != ESP_OK) {
ESP_LOGW(TAG, "SD card HTTP server failed to start");
}
#ifdef CONFIG_ESP_WIFI_CSI_ENABLED
ESP_ERROR_CHECK(csi_log_init());
csi_mgr_init();
@ -201,6 +208,9 @@ void app_main(void) {
wifi_ctl_init();
iperf_param_init();
/* Broadcast beacon: advertise device (IP, MAC, fiwi-telemetry) for laptop discovery */
broadcast_beacon_init();
// 6. Initialize Console (REPL)
ESP_LOGI(TAG, "Initializing console REPL...");
esp_console_repl_t *repl = NULL;

178
tools/beacon_listen.py Normal file
View File

@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""
Listen for ESP32 broadcast beacons and optionally download fiwi-telemetry from each device.
The device sends UDP broadcast packets (port 5555) with JSON:
{"ip":"...","mask":"...","gw":"...","dhcp":"ON|OFF","mac":"...","fiwi_telemetry":true|false}
Devices are tracked by MAC address (unique per device). For each device with fiwi_telemetry=true,
the script downloads http://<ip>:8080/sdcard/fiwi-telemetry and saves to the output directory
with a filename including the MAC for uniqueness.
Usage:
python3 tools/beacon_listen.py [options]
# Listen only (no download):
python3 tools/beacon_listen.py
# Listen and download telemetry to ./telemetry/:
python3 tools/beacon_listen.py --download --output-dir ./telemetry
# Refresh downloads every 60 seconds:
python3 tools/beacon_listen.py --download --output-dir ./telemetry --refresh 60
Requires: standard library only (socket, json, urllib)
"""
import argparse
import json
import os
import socket
import sys
import time
import urllib.error
import urllib.request
BEACON_PORT = 5555
HTTP_PORT = 8080
TELEMETRY_PATH = "fiwi-telemetry"
def mac_to_filename(mac: str) -> str:
"""Convert MAC like 3c:dc:75:82:2a:a8 to safe filename suffix 3cdc75822aa8."""
return mac.replace(":", "").replace("-", "").lower()
def download_telemetry(ip: str, mac: str, output_dir: str) -> bool:
"""Download fiwi-telemetry from device at ip, save to output_dir/fiwi-telemetry_<mac>."""
url = f"http://{ip}:{HTTP_PORT}/sdcard/{TELEMETRY_PATH}"
suffix = mac_to_filename(mac)
out_path = os.path.join(output_dir, f"fiwi-telemetry_{suffix}")
try:
with urllib.request.urlopen(url, timeout=10) as resp:
data = resp.read()
with open(out_path, "wb") as f:
f.write(data)
return True
except (urllib.error.URLError, OSError, TimeoutError) as e:
sys.stderr.write(f"Download failed {url}: {e}\n")
return False
def main():
ap = argparse.ArgumentParser(
description="Listen for ESP32 broadcast beacons and download fiwi-telemetry"
)
ap.add_argument(
"-p", "--port", type=int, default=BEACON_PORT,
help=f"UDP beacon port (default {BEACON_PORT})",
)
ap.add_argument(
"-d", "--download", action="store_true",
help="Download fiwi-telemetry from devices that advertise it",
)
ap.add_argument(
"-o", "--output-dir", default="./telemetry",
help="Directory to save telemetry files (default ./telemetry)",
)
ap.add_argument(
"-r", "--refresh", type=float, default=0,
help="Re-download telemetry every N seconds (0 = once per device, default)",
)
ap.add_argument(
"-q", "--quiet", action="store_true",
help="Less output; only print downloads and errors",
)
args = ap.parse_args()
if args.download:
os.makedirs(args.output_dir, exist_ok=True)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
except Exception:
pass
sock.bind(("", args.port))
sock.settimeout(1.0)
# Track devices by MAC: {mac: {"ip": ..., "last_download": timestamp, ...}}
devices = {}
if not args.quiet:
print(f"Listening for beacons on UDP port {args.port}...")
if args.download:
print(f"Downloading telemetry to {args.output_dir}/")
print("Ctrl+C to stop.\n")
while True:
try:
data, addr = sock.recvfrom(1024)
except socket.timeout:
# Periodic refresh: re-download devices with fiwi_telemetry if --refresh set
if args.download and args.refresh > 0 and devices:
now = time.time()
for mac, info in list(devices.items()):
if not info.get("fiwi_telemetry"):
continue
last = info.get("last_download", 0)
if now - last >= args.refresh:
if download_telemetry(info["ip"], mac, args.output_dir):
info["last_download"] = now
if not args.quiet:
print(f"Refresh: {mac} -> fiwi-telemetry_{mac_to_filename(mac)}")
continue
except KeyboardInterrupt:
break
try:
obj = json.loads(data.decode().strip())
except (json.JSONDecodeError, UnicodeDecodeError) as e:
if not args.quiet:
print(f"[{addr[0]}] Invalid JSON: {e}", file=sys.stderr)
continue
ip = obj.get("ip", "?")
mac = obj.get("mac", "?")
dhcp = obj.get("dhcp", "?")
fiwi = obj.get("fiwi_telemetry", False)
if mac == "?":
continue
is_new = mac not in devices
devices[mac] = {
"ip": ip,
"dhcp": dhcp,
"fiwi_telemetry": fiwi,
"last_download": devices.get(mac, {}).get("last_download", 0),
}
if not args.quiet or (args.download and fiwi and is_new):
print(f"Device: {ip} | MAC: {mac} | DHCP: {dhcp} | fiwi-telemetry: {fiwi}")
if args.download and fiwi:
should_download = is_new or (
args.refresh > 0
and (time.time() - devices[mac]["last_download"]) >= args.refresh
)
if should_download:
if download_telemetry(ip, mac, args.output_dir):
devices[mac]["last_download"] = time.time()
print(f" Downloaded -> {args.output_dir}/fiwi-telemetry_{mac_to_filename(mac)}")
else:
print(f" Download failed for {ip}", file=sys.stderr)
if not args.quiet and is_new:
print()
sock.close()
if not args.quiet:
print(f"\nSeen {len(devices)} unique device(s).")
if __name__ == "__main__":
main()

114
tools/sdcard_recv.py Normal file
View File

@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
Receive a file from the ESP32 SD card over serial.
The device must be running the sdcard send command; this script sends the
command and captures the hex-encoded output, then decodes and saves to a file.
Usage:
python3 tools/sdcard_recv.py -p /dev/ttyUSB0 -f myfile.txt [-o output.bin]
python3 tools/sdcard_recv.py --port /dev/ttyUSB0 --remote myfile.txt
Requires: pyserial (pip install pyserial)
"""
import argparse
import re
import sys
import time
try:
import serial
except ImportError:
print("Error: pyserial required. Run: pip install pyserial", file=sys.stderr)
sys.exit(1)
def main():
ap = argparse.ArgumentParser(description="Receive file from ESP32 SD card over serial")
ap.add_argument("-p", "--port", required=True, help="Serial port (e.g. /dev/ttyUSB0)")
ap.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default 115200)")
ap.add_argument("-f", "--remote", "--file", dest="remote", required=True,
help="Path of file on the SD card (e.g. myfile.txt or log/data.bin)")
ap.add_argument("-o", "--output", help="Local output path (default: basename of remote file)")
ap.add_argument("-t", "--timeout", type=float, default=60.0,
help="Timeout in seconds for transfer (default 60)")
args = ap.parse_args()
out_path = args.output
if not out_path:
out_path = args.remote.split("/")[-1].split("\\")[-1] or "received.bin"
ser = serial.Serial(args.port, args.baud, timeout=1.0)
# Drain any pending input
ser.reset_input_buffer()
# Send: sdcard send <path>\r\n
cmd = f"sdcard send {args.remote}\r\n"
ser.write(cmd.encode("ascii"))
ser.flush()
# Wait for ---SDFILE---
marker_start = b"---SDFILE---"
marker_end = b"---END SDFILE---"
line_buf = b""
state = "wait_start"
remote_name = None
size_val = None
hex_buf = []
deadline = time.time() + args.timeout
while True:
if time.time() > deadline:
print("Timeout waiting for transfer", file=sys.stderr)
sys.exit(1)
c = ser.read(1)
if not c:
continue
line_buf += c
if c != b"\n" and c != b"\r":
if len(line_buf) > 2048:
line_buf = line_buf[-1024:]
continue
line = line_buf.decode("ascii", errors="ignore").strip()
line_buf = b""
if state == "wait_start":
if marker_start.decode() in line or line == "---SDFILE---":
state = "read_meta"
continue
if state == "read_meta":
if line.startswith("SIZE:"):
try:
size_val = int(line.split(":", 1)[1].strip())
except ValueError:
size_val = 0
state = "wait_hex"
elif line and not line.startswith("---"):
remote_name = line
continue
if state == "wait_hex":
if "---HEX---" in line:
state = "read_hex"
continue
if state == "read_hex":
if marker_end.decode() in line or line == "---END SDFILE---":
break
# Strip non-hex and decode
hex_part = re.sub(r"[^0-9a-fA-F]", "", line)
if hex_part:
hex_buf.append(hex_part)
continue
# Decode hex and write
raw = bytes.fromhex("".join(hex_buf))
if size_val is not None and len(raw) != size_val:
print(f"Warning: size mismatch (expected {size_val}, got {len(raw)})", file=sys.stderr)
with open(out_path, "wb") as f:
f.write(raw)
print(f"Saved {len(raw)} bytes to {out_path}")
ser.close()
if __name__ == "__main__":
main()