diff --git a/components/app_console/CMakeLists.txt b/components/app_console/CMakeLists.txt index 4cabb9e..2bffb7f 100644 --- a/components/app_console/CMakeLists.txt +++ b/components/app_console/CMakeLists.txt @@ -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 ) diff --git a/components/app_console/cmd_sdcard.c b/components/app_console/cmd_sdcard.c index 635bb12..297777b 100644 --- a/components/app_console/cmd_sdcard.c +++ b/components/app_console/cmd_sdcard.c @@ -33,12 +33,29 @@ #include #include +#include #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 \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 ---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 \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 [args]\n"); + printf("Usage: sdcard [args]\n"); printf(" status - Show CD, mounted, capacity\n"); + printf(" list [path] - List files (path optional, default root)\n"); printf(" write - Write text to file\n"); printf(" read - Read and print file\n"); + printf(" send - Stream file over serial (use tools/sdcard_recv.py)\n"); + printf(" delete - 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 , read ", - .hint = "", + .help = "SD card: status, list [path], write, read, send, delete ", + .hint = "", .func = &cmd_sdcard, }; ESP_ERROR_CHECK(esp_console_cmd_register(&cmd)); diff --git a/components/mcs_telemetry/CMakeLists.txt b/components/mcs_telemetry/CMakeLists.txt new file mode 100644 index 0000000..0537c5d --- /dev/null +++ b/components/mcs_telemetry/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "mcs_telemetry.c" + INCLUDE_DIRS "." + REQUIRES esp_wifi esp_timer wifi_monitor +) + diff --git a/components/mcs_telemetry/mcs_telemetry.c b/components/mcs_telemetry/mcs_telemetry.c new file mode 100644 index 0000000..f978a63 --- /dev/null +++ b/components/mcs_telemetry/mcs_telemetry.c @@ -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 +#include + +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; +} + diff --git a/components/mcs_telemetry/mcs_telemetry.h b/components/mcs_telemetry/mcs_telemetry.h new file mode 100644 index 0000000..9e41212 --- /dev/null +++ b/components/mcs_telemetry/mcs_telemetry.h @@ -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 +#include + +// 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 + diff --git a/components/sd_card/sd_card.c b/components/sd_card/sd_card.c index 2d3d1e1..9736db9 100644 --- a/components/sd_card/sd_card.c +++ b/components/sd_card/sd_card.c @@ -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 \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; diff --git a/components/sd_card/sd_card.h b/components/sd_card/sd_card.h index 195b426..ed80619 100644 --- a/components/sd_card/sd_card.h +++ b/components/sd_card/sd_card.h @@ -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 * diff --git a/components/sdcard_http/CMakeLists.txt b/components/sdcard_http/CMakeLists.txt new file mode 100644 index 0000000..bdb5bee --- /dev/null +++ b/components/sdcard_http/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "sdcard_http.c" + INCLUDE_DIRS "." + REQUIRES sd_card esp_http_server +) diff --git a/components/sdcard_http/sdcard_http.c b/components/sdcard_http/sdcard_http.c new file mode 100644 index 0000000..04fb2f2 --- /dev/null +++ b/components/sdcard_http/sdcard_http.c @@ -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/. + * Use: wget http://: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 +#include +#include +#include +#include +#include + +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://:8080/sdcard/"); + 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"); + } +} diff --git a/components/sdcard_http/sdcard_http.h b/components/sdcard_http/sdcard_http.h new file mode 100644 index 0000000..5effb49 --- /dev/null +++ b/components/sdcard_http/sdcard_http.h @@ -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/ + * 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 */ diff --git a/components/wifi_controller/CMakeLists.txt b/components/wifi_controller/CMakeLists.txt index c9003f0..231f37d 100644 --- a/components/wifi_controller/CMakeLists.txt +++ b/components/wifi_controller/CMakeLists.txt @@ -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) diff --git a/components/wifi_controller/wifi_controller.c b/components/wifi_controller/wifi_controller.c index 84f9a5c..42df98f 100644 --- a/components/wifi_controller/wifi_controller.c +++ b/components/wifi_controller/wifi_controller.c @@ -38,6 +38,7 @@ #include "esp_event.h" #include "esp_netif.h" #include "inttypes.h" +#include #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)); diff --git a/doc/SD_CARD_WIRING.md b/doc/SD_CARD_WIRING.md index 1acb0a8..912bd24 100644 --- a/doc/SD_CARD_WIRING.md +++ b/doc/SD_CARD_WIRING.md @@ -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://:8080/sdcard/` +- 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 +``` + +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_` 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:** diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 68e1a79..c2379f0 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -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) diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index 0e192d2..7e9f61d 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -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)" diff --git a/main/broadcast_beacon.c b/main/broadcast_beacon.c new file mode 100644 index 0000000..232f292 --- /dev/null +++ b/main/broadcast_beacon.c @@ -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 +#include +#include +#include +#include + +#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; +} diff --git a/main/broadcast_beacon.h b/main/broadcast_beacon.h new file mode 100644 index 0000000..7d3d571 --- /dev/null +++ b/main/broadcast_beacon.h @@ -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 */ diff --git a/main/main.c b/main/main.c index fc0d81e..9cf9093 100644 --- a/main/main.c +++ b/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/ 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; diff --git a/tools/beacon_listen.py b/tools/beacon_listen.py new file mode 100644 index 0000000..3c0f2a2 --- /dev/null +++ b/tools/beacon_listen.py @@ -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://: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_.""" + 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() diff --git a/tools/sdcard_recv.py b/tools/sdcard_recv.py new file mode 100644 index 0000000..e48d552 --- /dev/null +++ b/tools/sdcard_recv.py @@ -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 \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()