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:
parent
a4e81c9852
commit
1eb04acd25
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
idf_component_register(
|
||||
SRCS "mcs_telemetry.c"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES esp_wifi esp_timer wifi_monitor
|
||||
)
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
idf_component_register(
|
||||
SRCS "sdcard_http.c"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES sd_card esp_http_server
|
||||
)
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 Wi‑Fi (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 Wi‑Fi, 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:**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 */
|
||||
10
main/main.c
10
main/main.c
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue