diff --git a/README.md b/README.md index 60498f5..d9fe401 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,209 @@ Some hotspot implementations isolate connected clients from the hotspot host or The ESP32-C5 has one Wi-Fi radio. Active dual-band scouting temporarily visits channels away from the associated AP. BandiC5 explicitly uses Espressif's home-channel dwell between scan channels to help preserve the infrastructure connection, but brief latency during scans is expected. Uplink monitoring is intentionally low-bandwidth and read-only. +## v0.4 web observability + +v0.4 turns the existing uplink page into a practical read-only RF and logging console. + +### Latest-scan AP table + +The browser shows a table from the most recently **completed** RF scan: + +- SSID +- BSSID +- 2.4 GHz / 5 GHz band +- channel +- RSSI +- authentication mode + +Rows are sorted strongest-first. + +The device keeps a bounded cache of the strongest 64 APs. If a scan sees more than 64 APs, the JSON response marks the table as truncated rather than consuming unbounded RAM. The browser never reads a half-built scan; the working set is published only when a complete scan succeeds. + +The raw endpoint is: + +`/aps.json` + +SSID values are inserted into the browser through DOM `textContent`, not interpreted as HTML. + +### TF session browser and downloads + +When a TF card is mounted, the web page lists the newest 32 numbered BandiC5 sessions and provides a download link for each. + +Downloads use a numeric session parameter: + +`/download?session=17` + +The firmware reconstructs the expected `session-0017.csv` path internally. Arbitrary filesystem paths are never accepted from the browser. + +The current active session is marked separately and is intentionally not downloadable while it is still open for writing. Completed session files are stable and downloadable. + +Session metadata is available from: + +`/logs.json` + +CSV files are streamed in small chunks rather than loaded into RAM. + + +### Mobile CSV download framing + +v0.4.1 changes completed-session downloads from HTTP chunked transfer to a fixed +`Content-Length` response. The file is still streamed from TF in 2 KiB pieces, +but mobile browsers/download managers can now display the real attachment size +instead of `-1 byte` / unknown length. + + +### Single-radio CSV transfer guard + +v0.4.2 serializes active RF scans and completed-session downloads with a shared +radio mutex. A download waits for any current channel sweep to finish, then +temporarily keeps the single ESP32-C5 Wi-Fi radio on the associated AP's home +channel while streaming the CSV. RF scanning resumes automatically as soon as +the transfer ends. + +This addresses mobile transfers that received the correct `Content-Length` but +stalled at zero bytes when an active dual-band scan moved the STA off-channel. +The web UI also retains the last known status/table during brief server-busy +periods instead of replacing good data with a transient unavailable message. + + +### Browser-managed CSV downloads + +v0.4.3 removes the hand-framed fixed-length HTTP response used in v0.4.1/v0.4.2. +Completed-session CSVs are again sent with ESP-IDF's supported chunked response +API, but the status page now fetches the file itself, tracks received bytes +against the known session size, assembles a browser Blob, and triggers the local +download only after the transfer completes. + +While a file transfer is active: +- normal status/AP/session polling is paused +- the shared radio mutex prevents new RF sweeps +- the HTTP send timeout is extended to 20 seconds +- polling and scanning resume automatically after success or failure + +This avoids mobile download-manager quirks around local chunked attachments and +also avoids leaving the ESP-IDF HTTP server in an inconsistent state after a +manually framed response. + +### v0.3.1 recovery landmark + +The branch `release/v0.3.1` points at the exact physically validated v0.3.1 mainline commit. The connected GitHub tooling used for this project can mutate branches/PRs but does not expose Git tag/release-asset creation, so this branch is the durable recovery pointer until a GitHub Release/tag is created separately. + +## v0.4.5 static RC + +This pass addresses the three merge blockers found by the adversarial re-review +of v0.4.4, plus several small safety fixes that were cheap to close before +hardware testing. + +### Uplink connection state + +- association/discovery now has a 15-second window instead of a 3-second total + connect deadline +- `WIFI_EVENT_STA_CONNECTED` starts a separate 10-second DHCP window +- the CONNECTING state is published before `esp_wifi_connect()`, so an + unusually fast GOT_IP event cannot be overwritten by the caller +- connect attempts now own the same radio mutex as scans and downloads for the + asynchronous attempt lifetime +- LOST_IP is treated as a DHCP-recovery state instead of immediately issuing a + second connect while still associated +- a deferred scout scan gets priority before another connect or download starts + +### Completed-session downloads + +- the 30-second total transfer cap is removed +- ESP-IDF's configured socket send timeout remains the server-side stalled-client + bound +- the browser's 30-second timer is now inactivity-based and is re-armed whenever + a stream chunk arrives +- completed sessions are validated/opened before attempting to reserve the radio +- downloads back off when a scout scan is already waiting +- the radio wait for a download is capped at one second +- an in-flight download exits at the next chunk boundary when uplink shutdown + begins, so BOOT/OTA entry is not forced to wait for a healthy long transfer + +### OTA rollback interaction + +A pending OTA image is confirmed after the update AP and updater HTTP server +successfully start. That recovery path is sufficient evidence to make update +mode usable: a second firmware upload and a Wi-Fi-settings reboot can no longer +be rejected or silently roll the new image back during its normal health window. +Both POST handlers also confirm defensively, closing the tiny race between the +HTTP server accepting its first request and the control task's post-start check. + +Normal scout-mode confirmation is still delayed until a successful scan/publish, +the healthy-runtime window, and first uplink-attempt resolution. + +### Session creation safety + +- directory enumeration errors are distinguished from normal end-of-directory +- new session files are created with exclusive-create semantics +- a case-variant or otherwise previously missed filename cannot be silently + truncated by opening it with create-always behavior + +## v0.4.4 hardening RC + +This release-candidate pass follows an adversarial whole-repository review and +focuses on concurrency, recovery, and long-run reliability rather than adding +new user-facing features. + +### TF/FAT lifetime safety + +The storage module now owns a mutex and explicit completed-session reader leases. + +- storage state becomes unavailable before unmount begins +- FAT/VFS unmount is deferred while any web reader is still open +- no new completed-session reader can open after teardown starts +- web downloads use storage-owned open/read/close wrappers instead of touching + FATFS directly +- session listing is serialized with teardown and stats only the newest retained + entries +- session allocation scans the directory once for the highest canonical session + number instead of probing every number with repeated `stat()` +- session names must exactly match `session-NNNN.csv` +- failed initial session creation removes the incomplete file +- CSV cells beginning with spreadsheet formula trigger characters are prefixed + to prevent formula execution when logs are opened in spreadsheet software + +### Scout/uplink arbitration + +Radio ownership is now nonblocking from the scout control loop. + +- scans use a try-lock instead of waiting forever behind a browser transfer +- the control loop continues servicing BOOT/OTA, storage recovery, and uplink + state while the radio is busy +- uplink connection attempts have an explicit connecting state and a 3-second + bounded opportunity before the radio returns to scouting +- scan-start `ESP_ERR_WIFI_STATE` is treated as a deferral rather than a hard + scan error +- DHCP lost-IP events are handled +- Wi-Fi configuration uses RAM storage; the BandiC5 NVS namespace remains the + credential source of truth + +### HTTP reliability + +- status server enables LRU socket purge and TCP keepalive +- completed CSV transfers are bounded to 30 seconds server-side +- browser-side fetches are also aborted after 30 seconds +- normal page polling pauses during a CSV transfer +- downloads retain the radio guard but can no longer park the main control loop + +### OTA and event-task hardening + +- HTTP and mDNS startup moved out of the ESP-IDF system event task +- Wi-Fi/IP event callbacks now only update small state flags +- update and Wi-Fi-config POST receives abort after six consecutive socket + timeouts instead of wedging update mode indefinitely +- pending OTA images are no longer confirmed immediately after task creation +- confirmation waits for a successful scan/publish path, a 30-second healthy + runtime window, and resolution of the first uplink attempt when configured +- failed confirmation is retried rather than latched as successful + +### Scan failure cleanup + +Wi-Fi driver AP lists are explicitly cleared on count/allocation/retrieval +failure paths so a dense-environment allocation failure does not retain the +driver's scan result memory. + ## Build The project targets **ESP-IDF v5.5.5** and **esp32c5**. diff --git a/main/app_main.c b/main/app_main.c index f82f5a4..41559ff 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -2,6 +2,8 @@ #include "freertos/task.h" #include "esp_log.h" +#include "esp_timer.h" +#include "esp_wifi.h" #include "esp_system.h" #include "bandit_ota.h" @@ -14,6 +16,7 @@ #define SCAN_TASK_STACK_SIZE (8 * 1024) #define SCAN_INTERVAL_MS 3500 #define OTA_REQUEST_POLL_MS 100 +#define OTA_CONFIRM_HEALTH_MS 30000 static const char *TAG = "bandic5"; @@ -48,6 +51,16 @@ static void enter_ota_mode(void) esp_restart(); } + // A live updater AP + HTTP server is the recovery path rollback is meant + // to preserve. Confirm a pending image here so update-mode uploads and a + // Wi-Fi-settings reboot cannot accidentally roll this working image back. + if (!bandit_ota_confirm_running_image()) { + ESP_LOGE(TAG, "unable to confirm running image after OTA recovery started"); + bandit_ui_set_ota_progress(0, "OTA VERIFY FAILED"); + vTaskDelay(pdMS_TO_TICKS(2500)); + esp_restart(); + } + // The HTTP server owns the updater from here. This task is no longer needed. vTaskDelete(NULL); } @@ -72,10 +85,21 @@ static void wait_for_next_scan_or_ota(void) } } +static void handle_scan_record(const bandit_scan_record_t *record, void *ctx) +{ + (void)ctx; + bandit_storage_log_record(record, NULL); + bandit_uplink_observe_record(record); +} + static void scan_task(void *arg) { (void)arg; + bool first_scan_ok = false; + bool ota_confirmed = false; + int64_t healthy_since_ms = 0; + while (1) { if (bandit_ota_requested()) { enter_ota_mode(); @@ -85,13 +109,21 @@ static void scan_task(void *arg) bandit_ui_set_storage_state(storage_ui_state()); bandit_scan_snapshot_t snapshot; + + if (!bandit_uplink_try_begin_scan()) { + bandit_ui_set_status("RADIO BUSY"); + wait_for_next_scan_or_ota(); + continue; + } + bandit_ui_set_status("SCANNING"); esp_err_t ret = bandit_scan_once( &snapshot, - bandit_storage_log_record, + handle_scan_record, NULL ); + bandit_uplink_end_scan(); if (ret == ESP_OK) { bandit_storage_finish_scan(snapshot.generation); @@ -99,6 +131,11 @@ static void scan_task(void *arg) bandit_ui_update(&snapshot); bandit_uplink_publish_snapshot(&snapshot); + if (!first_scan_ok) { + first_scan_ok = true; + healthy_since_ms = esp_timer_get_time() / 1000; + } + ESP_LOGI( TAG, "scan=%lu aps=%u 2g=%u 5g=%u strongest=%s rssi=%d ch=%u", @@ -110,12 +147,23 @@ static void scan_task(void *arg) snapshot.strongest_rssi, snapshot.strongest_channel ); + } else if (ret == ESP_ERR_WIFI_STATE) { + ESP_LOGW(TAG, "scan deferred while Wi-Fi driver is busy"); + bandit_ui_set_storage_state(storage_ui_state()); + bandit_ui_set_status("SCAN DEFER"); } else { ESP_LOGE(TAG, "scan failed: %s", esp_err_to_name(ret)); bandit_ui_set_storage_state(storage_ui_state()); bandit_ui_set_status("SCAN ERROR"); } + if (!ota_confirmed && + first_scan_ok && + bandit_uplink_first_attempt_resolved() && + (esp_timer_get_time() / 1000) - healthy_since_ms >= OTA_CONFIRM_HEALTH_MS) { + ota_confirmed = bandit_ota_confirm_running_image(); + } + if (bandit_ota_requested()) { enter_ota_mode(); } @@ -140,7 +188,13 @@ void app_main(void) bandit_ui_set_storage_state(storage_ui_state()); if (ret == ESP_OK) { - ESP_LOGI(TAG, "TF logging enabled: %s", bandit_storage_get_session_path()); + char session_path[96]; + if (bandit_storage_get_session_path( + session_path, + sizeof(session_path) + ) == ESP_OK) { + ESP_LOGI(TAG, "TF logging enabled: %s", session_path); + } } else { ESP_LOGW( TAG, @@ -188,7 +242,6 @@ void app_main(void) return; } - // A newly OTA-flashed image is accepted only after display, radio, OTA trigger, - // and the main scan task have all initialized successfully. - bandit_ota_confirm_running_image(); + // Pending OTA images are confirmed by scan_task only after real runtime + // behavior has been exercised for the health window. } diff --git a/main/bandit_ota.c b/main/bandit_ota.c index 70cadad..0a15d8c 100644 --- a/main/bandit_ota.c +++ b/main/bandit_ota.c @@ -27,6 +27,7 @@ #define BANDIT_OTA_POLL_MS 100 #define BANDIT_OTA_BUTTON_TASK_STACK 3072 #define BANDIT_OTA_RECV_BUFFER 4096 +#define BANDIT_OTA_MAX_RECV_TIMEOUTS 6 #define BANDIT_OTA_REBOOT_DELAY_MS 4000 static const char *TAG = "bandit_ota"; @@ -225,6 +226,17 @@ static bool form_value( static esp_err_t wifi_config_post_handler(httpd_req_t *req) { + // If this is a freshly OTA-booted image, reaching this handler proves the + // recovery AP and HTTP server are alive. Confirm before any reboot path. + if (!bandit_ota_confirm_running_image()) { + httpd_resp_send_err( + req, + HTTPD_500_INTERNAL_SERVER_ERROR, + "Unable to confirm running image" + ); + return ESP_FAIL; + } + if (req->content_len == 0 || req->content_len >= 384) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid Wi-Fi form"); return ESP_FAIL; @@ -233,10 +245,16 @@ static esp_err_t wifi_config_post_handler(httpd_req_t *req) char body[384]; size_t remaining = req->content_len; size_t offset = 0; + unsigned timeout_count = 0; while (remaining > 0) { int received = httpd_req_recv(req, body + offset, remaining); if (received == HTTPD_SOCK_ERR_TIMEOUT) { + timeout_count++; + if (timeout_count >= BANDIT_OTA_MAX_RECV_TIMEOUTS) { + httpd_resp_set_status(req, "408 Request Timeout"); + return httpd_resp_sendstr(req, "Wi-Fi form receive timed out"); + } continue; } if (received <= 0) { @@ -244,6 +262,7 @@ static esp_err_t wifi_config_post_handler(httpd_req_t *req) return ESP_FAIL; } + timeout_count = 0; offset += (size_t)received; remaining -= (size_t)received; } @@ -286,6 +305,18 @@ static esp_err_t wifi_config_post_handler(httpd_req_t *req) static esp_err_t update_post_handler(httpd_req_t *req) { + // A request can race the control task's post-start confirmation by a tiny + // window. Confirm here too so esp_ota_begin() is never attempted while the + // current image is still PENDING_VERIFY. + if (!bandit_ota_confirm_running_image()) { + httpd_resp_send_err( + req, + HTTPD_500_INTERNAL_SERVER_ERROR, + "Unable to confirm running image" + ); + return ESP_FAIL; + } + ESP_LOGI( TAG, "HTTP OTA upload start, stack high water=%u", @@ -335,6 +366,7 @@ static esp_err_t update_post_handler(httpd_req_t *req) size_t remaining = req->content_len; size_t received_total = 0; int last_percent = -1; + unsigned timeout_count = 0; while (remaining > 0) { size_t to_read = remaining < BANDIT_OTA_RECV_BUFFER @@ -343,6 +375,15 @@ static esp_err_t update_post_handler(httpd_req_t *req) int received = httpd_req_recv(req, buffer, to_read); if (received == HTTPD_SOCK_ERR_TIMEOUT) { + timeout_count++; + if (timeout_count >= BANDIT_OTA_MAX_RECV_TIMEOUTS) { + ESP_LOGE(TAG, "firmware upload timed out"); + esp_ota_abort(ota_handle); + free(buffer); + bandit_ui_set_ota_progress(0, "UPLOAD TIMEOUT"); + httpd_resp_set_status(req, "408 Request Timeout"); + return httpd_resp_sendstr(req, "Firmware upload timed out"); + } continue; } @@ -355,6 +396,8 @@ static esp_err_t update_post_handler(httpd_req_t *req) return ESP_FAIL; } + timeout_count = 0; + ret = esp_ota_write(ota_handle, buffer, received); if (ret != ESP_OK) { ESP_LOGE(TAG, "esp_ota_write failed: %s", esp_err_to_name(ret)); @@ -569,26 +612,30 @@ esp_err_t bandit_ota_start(void) return ESP_OK; } -void bandit_ota_confirm_running_image(void) +bool bandit_ota_confirm_running_image(void) { const esp_partition_t *running = esp_ota_get_running_partition(); if (!running) { - return; + return false; } esp_ota_img_states_t state; if (esp_ota_get_state_partition(running, &state) != ESP_OK) { - return; + return false; } - if (state == ESP_OTA_IMG_PENDING_VERIFY) { - esp_err_t ret = esp_ota_mark_app_valid_cancel_rollback(); - if (ret == ESP_OK) { - ESP_LOGI(TAG, "confirmed OTA image %s as valid", running->label); - } else { - ESP_LOGE(TAG, "failed to confirm OTA image: %s", esp_err_to_name(ret)); - } + if (state != ESP_OTA_IMG_PENDING_VERIFY) { + return true; + } + + esp_err_t ret = esp_ota_mark_app_valid_cancel_rollback(); + if (ret == ESP_OK) { + ESP_LOGI(TAG, "confirmed OTA image %s as valid", running->label); + return true; } + + ESP_LOGE(TAG, "failed to confirm OTA image: %s", esp_err_to_name(ret)); + return false; } void bandit_ota_rollback_if_pending(void) diff --git a/main/bandit_ota.h b/main/bandit_ota.h index 1bea6c8..ddd85b4 100644 --- a/main/bandit_ota.h +++ b/main/bandit_ota.h @@ -12,5 +12,5 @@ esp_err_t bandit_ota_button_init(void); bool bandit_ota_requested(void); esp_err_t bandit_ota_start(void); -void bandit_ota_confirm_running_image(void); +bool bandit_ota_confirm_running_image(void); void bandit_ota_rollback_if_pending(void); diff --git a/main/bandit_scan.c b/main/bandit_scan.c index 089ad8e..6478367 100644 --- a/main/bandit_scan.c +++ b/main/bandit_scan.c @@ -136,37 +136,39 @@ esp_err_t bandit_scan_once( ESP_RETURN_ON_ERROR(esp_wifi_scan_start(&scan_cfg, true), TAG, "scan failed"); uint16_t ap_count = 0; - ESP_RETURN_ON_ERROR(esp_wifi_scan_get_ap_num(&ap_count), TAG, "AP count failed"); + esp_err_t ret = esp_wifi_scan_get_ap_num(&ap_count); + if (ret != ESP_OK) { + (void)esp_wifi_clear_ap_list(); + return ret; + } snapshot->generation = ++s_generation; snapshot->total = ap_count; if (ap_count == 0) { + (void)esp_wifi_clear_ap_list(); return ESP_OK; } - wifi_ap_record_t *records = calloc(ap_count, sizeof(*records)); - if (!records) { - return ESP_ERR_NO_MEM; - } - - uint16_t record_count = ap_count; - esp_err_t ret = esp_wifi_scan_get_ap_records(&record_count, records); - if (ret != ESP_OK) { - free(records); - return ret; - } - const int64_t observed_ms = esp_timer_get_time() / 1000; - for (uint16_t i = 0; i < record_count; i++) { - const wifi_ap_record_t *record = &records[i]; - const bool hidden = record->ssid[0] == 0; + // Consume one driver-owned AP record at a time. ESP-IDF frees each record + // as it is fetched, avoiding a second ap_count-sized application buffer + // while the Wi-Fi driver's scan list is still resident in internal RAM. + for (uint16_t i = 0; i < ap_count; i++) { + wifi_ap_record_t record; + ret = esp_wifi_scan_get_ap_record(&record); + if (ret != ESP_OK) { + (void)esp_wifi_clear_ap_list(); + return ret; + } + + const bool hidden = record.ssid[0] == 0; char ssid[33]; - ssid_to_text(record->ssid, ssid); + ssid_to_text(record.ssid, ssid); - if (record->primary > 14) { + if (record.primary > 14) { snapshot->count_5g++; } else { snapshot->count_2g++; @@ -176,13 +178,13 @@ esp_err_t bandit_scan_once( snapshot->hidden++; } - if (record->authmode == WIFI_AUTH_OPEN) { + if (record.authmode == WIFI_AUTH_OPEN) { snapshot->open++; } - if (record->rssi > snapshot->strongest_rssi) { - snapshot->strongest_rssi = record->rssi; - snapshot->strongest_channel = record->primary; + if (record.rssi > snapshot->strongest_rssi) { + snapshot->strongest_rssi = record.rssi; + snapshot->strongest_channel = record.primary; snprintf( snapshot->strongest_ssid, sizeof(snapshot->strongest_ssid), @@ -193,7 +195,7 @@ esp_err_t bandit_scan_once( snapshot->strongest_auth, sizeof(snapshot->strongest_auth), "%s", - auth_name(record->authmode) + auth_name(record.authmode) ); } @@ -201,24 +203,23 @@ esp_err_t bandit_scan_once( bandit_scan_record_t emitted = { .generation = snapshot->generation, .uptime_ms = observed_ms, - .rssi = record->rssi, - .channel = record->primary, + .rssi = record.rssi, + .channel = record.primary, .hidden = hidden, }; - memcpy(emitted.bssid, record->bssid, sizeof(emitted.bssid)); + memcpy(emitted.bssid, record.bssid, sizeof(emitted.bssid)); snprintf(emitted.ssid, sizeof(emitted.ssid), "%s", ssid); snprintf( emitted.auth, sizeof(emitted.auth), "%s", - auth_name(record->authmode) + auth_name(record.authmode) ); record_callback(&emitted, record_callback_ctx); } } - free(records); return ESP_OK; } diff --git a/main/bandit_storage.c b/main/bandit_storage.c index 710b60c..a4f444e 100644 --- a/main/bandit_storage.c +++ b/main/bandit_storage.c @@ -1,13 +1,18 @@ #include "bandit_storage.h" +#include #include +#include #include +#include #include #include #include #include #include "bsp/esp-bsp.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" #include "esp_log.h" #include "esp_timer.h" @@ -19,51 +24,119 @@ static const char *TAG = "bandit_storage"; +struct bandit_storage_reader { + FILE *file; + size_t size_bytes; + unsigned index; +}; + static FILE *s_log_file; static bandit_storage_state_t s_state = BANDIT_STORAGE_NO_CARD; static char s_session_path[BANDIT_SESSION_PATH_MAX]; static uint32_t s_scans_since_flush; static bool s_mounted; +static bool s_unmount_pending; +static unsigned s_reader_count; static int64_t s_next_retry_ms; +static SemaphoreHandle_t s_mutex; static int64_t now_ms(void) { return esp_timer_get_time() / 1000; } -static void schedule_retry(void) +static void schedule_retry_locked(void) { s_next_retry_ms = now_ms() + BANDIT_STORAGE_RETRY_MS; } -static void teardown_storage(bandit_storage_state_t next_state) +static bool parse_session_filename(const char *name, unsigned *index) { - if (s_log_file) { - (void)fclose(s_log_file); - s_log_file = NULL; + if (!name || strlen(name) != strlen("session-0000.csv")) { + return false; } - if (s_mounted) { - esp_err_t ret = bsp_sdcard_unmount(); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "TF unmount returned %s", esp_err_to_name(ret)); + if (strncmp(name, "session-", 8) != 0 || + strcmp(name + 12, ".csv") != 0) { + return false; + } + + unsigned value = 0; + for (size_t i = 8; i < 12; i++) { + if (name[i] < '0' || name[i] > '9') { + return false; } - s_mounted = false; + value = (value * 10U) + (unsigned)(name[i] - '0'); + } + + if (value == 0 || value > BANDIT_SESSION_LIMIT) { + return false; } + if (index) { + *index = value; + } + return true; +} + +static void unmount_now_locked(void) +{ + if (!s_mounted) { + s_unmount_pending = false; + return; + } + + esp_err_t ret = bsp_sdcard_unmount(); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "TF unmount returned %s", esp_err_to_name(ret)); + } + + s_mounted = false; + s_unmount_pending = false; +} + +static void complete_pending_unmount_locked(void) +{ + if (s_unmount_pending && s_reader_count == 0) { + unmount_now_locked(); + } +} + +static void teardown_storage_locked(bandit_storage_state_t next_state) +{ + if (s_log_file) { + (void)fclose(s_log_file); + s_log_file = NULL; + } + + // Publish the volume as unavailable before touching the FAT/VFS mount so + // the HTTP task cannot acquire a new reader while teardown is in progress. s_state = next_state; s_session_path[0] = '\0'; s_scans_since_flush = 0; + + if (s_mounted) { + if (s_reader_count == 0) { + unmount_now_locked(); + } else { + s_unmount_pending = true; + ESP_LOGW( + TAG, + "deferring TF unmount for %u active reader(s)", + s_reader_count + ); + } + } } -static void set_error(const char *reason) +static void set_error_locked(const char *reason) { ESP_LOGE(TAG, "%s", reason ? reason : "storage error"); - teardown_storage(BANDIT_STORAGE_ERROR); - schedule_retry(); + teardown_storage_locked(BANDIT_STORAGE_ERROR); + schedule_retry_locked(); } -static esp_err_t flush_log(void) +static esp_err_t flush_log_locked(void) { if (!s_log_file) { return ESP_ERR_INVALID_STATE; @@ -83,7 +156,7 @@ static esp_err_t flush_log(void) return ESP_OK; } -static esp_err_t ensure_log_directory(void) +static esp_err_t ensure_log_directory_locked(void) { if (mkdir(BANDIT_LOG_DIR, 0775) == 0 || errno == EEXIST) { return ESP_OK; @@ -93,34 +166,83 @@ static esp_err_t ensure_log_directory(void) return ESP_FAIL; } -static esp_err_t choose_session_path(void) +esp_err_t bandit_storage_session_path( + unsigned index, + char *path, + size_t path_size +) { - struct stat st; + if (!path || path_size == 0 || index == 0 || index > BANDIT_SESSION_LIMIT) { + return ESP_ERR_INVALID_ARG; + } - for (unsigned index = 1; index <= BANDIT_SESSION_LIMIT; index++) { - int written = snprintf( - s_session_path, - sizeof(s_session_path), - BANDIT_LOG_DIR "/session-%04u.csv", - index - ); + int written = snprintf( + path, + path_size, + BANDIT_LOG_DIR "/session-%04u.csv", + index + ); - if (written <= 0 || written >= (int)sizeof(s_session_path)) { - return ESP_ERR_INVALID_SIZE; - } + if (written <= 0 || written >= (int)path_size) { + return ESP_ERR_INVALID_SIZE; + } - if (stat(s_session_path, &st) != 0) { - if (errno == ENOENT) { - return ESP_OK; - } + return ESP_OK; +} - ESP_LOGE(TAG, "stat %s failed: %s", s_session_path, strerror(errno)); - return ESP_FAIL; +static esp_err_t choose_session_start_index_locked(unsigned *next_index) +{ + if (!next_index) { + return ESP_ERR_INVALID_ARG; + } + + DIR *dir = opendir(BANDIT_LOG_DIR); + if (!dir) { + ESP_LOGE(TAG, "opendir %s failed: %s", BANDIT_LOG_DIR, strerror(errno)); + return ESP_FAIL; + } + + unsigned highest = 0; + errno = 0; + + struct dirent *entry; + while ((entry = readdir(dir)) != NULL) { + unsigned index = 0; + if (parse_session_filename(entry->d_name, &index) && index > highest) { + highest = index; } + errno = 0; + } + + int readdir_errno = errno; + closedir(dir); + + if (readdir_errno != 0) { + ESP_LOGE(TAG, "readdir %s failed: %s", BANDIT_LOG_DIR, strerror(readdir_errno)); + return ESP_FAIL; } - ESP_LOGE(TAG, "session namespace exhausted"); - return ESP_ERR_NO_MEM; + if (highest >= BANDIT_SESSION_LIMIT) { + ESP_LOGE(TAG, "session namespace exhausted"); + return ESP_ERR_NO_MEM; + } + + *next_index = highest + 1U; + return ESP_OK; +} + +static bool csv_formula_prefix_needed(const char *text) +{ + if (!text || !text[0]) { + return false; + } + + return text[0] == '=' || + text[0] == '+' || + text[0] == '-' || + text[0] == '@' || + text[0] == '\t' || + text[0] == '\r'; } static bool write_csv_text(FILE *file, const char *text) @@ -129,7 +251,12 @@ static bool write_csv_text(FILE *file, const char *text) return false; } - for (const char *p = text ? text : ""; *p; p++) { + const char *safe = text ? text : ""; + if (csv_formula_prefix_needed(safe) && fputc('\'', file) == EOF) { + return false; + } + + for (const char *p = safe; *p; p++) { if (*p == '"') { if (fputc('"', file) == EOF) { return false; @@ -144,49 +271,108 @@ static bool write_csv_text(FILE *file, const char *text) return fputc('"', file) != EOF; } -static esp_err_t start_session(void) +static esp_err_t start_session_locked(void) { + complete_pending_unmount_locked(); + + if (s_reader_count != 0 || s_unmount_pending) { + schedule_retry_locked(); + return ESP_ERR_INVALID_STATE; + } + if (s_log_file || s_mounted) { - teardown_storage(BANDIT_STORAGE_NO_CARD); + teardown_storage_locked(BANDIT_STORAGE_NO_CARD); + complete_pending_unmount_locked(); + if (s_mounted) { + schedule_retry_locked(); + return ESP_ERR_INVALID_STATE; + } } esp_err_t ret = bsp_sdcard_mount(); if (ret != ESP_OK) { s_state = BANDIT_STORAGE_NO_CARD; s_session_path[0] = '\0'; - schedule_retry(); + schedule_retry_locked(); return ret; } s_mounted = true; - ret = ensure_log_directory(); + ret = ensure_log_directory_locked(); if (ret != ESP_OK) { - teardown_storage(BANDIT_STORAGE_ERROR); - schedule_retry(); + teardown_storage_locked(BANDIT_STORAGE_ERROR); + schedule_retry_locked(); return ret; } - ret = choose_session_path(); + unsigned session_index = 0; + ret = choose_session_start_index_locked(&session_index); if (ret != ESP_OK) { - teardown_storage(BANDIT_STORAGE_ERROR); - schedule_retry(); + teardown_storage_locked(BANDIT_STORAGE_ERROR); + schedule_retry_locked(); return ret; } - s_log_file = fopen(s_session_path, "wb"); + for (; session_index <= BANDIT_SESSION_LIMIT; session_index++) { + ret = bandit_storage_session_path( + session_index, + s_session_path, + sizeof(s_session_path) + ); + if (ret != ESP_OK) { + teardown_storage_locked(BANDIT_STORAGE_ERROR); + schedule_retry_locked(); + return ret; + } + + int fd = open( + s_session_path, + O_WRONLY | O_CREAT | O_EXCL, + 0664 + ); + if (fd < 0) { + if (errno == EEXIST) { + continue; + } + + ESP_LOGE(TAG, "create %s failed: %s", s_session_path, strerror(errno)); + teardown_storage_locked(BANDIT_STORAGE_ERROR); + schedule_retry_locked(); + return ESP_FAIL; + } + + s_log_file = fdopen(fd, "w"); + if (!s_log_file) { + int fdopen_errno = errno; + (void)close(fd); + (void)unlink(s_session_path); + ESP_LOGE(TAG, "fdopen %s failed: %s", s_session_path, strerror(fdopen_errno)); + teardown_storage_locked(BANDIT_STORAGE_ERROR); + schedule_retry_locked(); + return ESP_FAIL; + } + + break; + } + if (!s_log_file) { - ESP_LOGE(TAG, "open %s failed: %s", s_session_path, strerror(errno)); - teardown_storage(BANDIT_STORAGE_ERROR); - schedule_retry(); - return ESP_FAIL; + ESP_LOGE(TAG, "session namespace exhausted"); + teardown_storage_locked(BANDIT_STORAGE_ERROR); + schedule_retry_locked(); + return ESP_ERR_NO_MEM; } if (fputs( "uptime_ms,scan,bssid,ssid,rssi,channel,band,auth,hidden\n", s_log_file ) == EOF || - flush_log() != ESP_OK) { - set_error("failed to initialize session log"); + flush_log_locked() != ESP_OK) { + char failed_path[BANDIT_SESSION_PATH_MAX]; + snprintf(failed_path, sizeof(failed_path), "%s", s_session_path); + (void)fclose(s_log_file); + s_log_file = NULL; + (void)unlink(failed_path); + set_error_locked("failed to initialize session log"); return ESP_FAIL; } @@ -200,10 +386,19 @@ static esp_err_t start_session(void) esp_err_t bandit_storage_init(void) { - teardown_storage(BANDIT_STORAGE_NO_CARD); + if (!s_mutex) { + s_mutex = xSemaphoreCreateMutex(); + if (!s_mutex) { + return ESP_ERR_NO_MEM; + } + } + + xSemaphoreTake(s_mutex, portMAX_DELAY); + teardown_storage_locked(BANDIT_STORAGE_NO_CARD); s_next_retry_ms = 0; + esp_err_t ret = start_session_locked(); + xSemaphoreGive(s_mutex); - esp_err_t ret = start_session(); if (ret != ESP_OK) { ESP_LOGW(TAG, "TF card unavailable: %s", esp_err_to_name(ret)); } @@ -213,36 +408,55 @@ esp_err_t bandit_storage_init(void) void bandit_storage_service(void) { - if (s_state == BANDIT_STORAGE_READY) { + if (!s_mutex) { + return; + } + + xSemaphoreTake(s_mutex, portMAX_DELAY); + + complete_pending_unmount_locked(); + + if (s_state == BANDIT_STORAGE_READY || + s_reader_count != 0 || + s_unmount_pending) { + xSemaphoreGive(s_mutex); return; } const int64_t current_ms = now_ms(); if (s_next_retry_ms != 0 && current_ms < s_next_retry_ms) { + xSemaphoreGive(s_mutex); return; } ESP_LOGI(TAG, "retrying TF card mount"); - esp_err_t ret = start_session(); + esp_err_t ret = start_session_locked(); if (ret == ESP_OK) { ESP_LOGI(TAG, "TF card recovered"); } else { - // A mount failure means no usable card is present. Filesystem/open failures - // leave the ERROR state set by start_session() so the UI can distinguish them. if (s_state != BANDIT_STORAGE_ERROR) { s_state = BANDIT_STORAGE_NO_CARD; } - schedule_retry(); + schedule_retry_locked(); ESP_LOGD(TAG, "TF retry unavailable: %s", esp_err_to_name(ret)); } + + xSemaphoreGive(s_mutex); } void bandit_storage_log_record(const bandit_scan_record_t *record, void *ctx) { (void)ctx; - if (!record || s_state != BANDIT_STORAGE_READY || !s_log_file) { + if (!record || !s_mutex) { + return; + } + + xSemaphoreTake(s_mutex, portMAX_DELAY); + + if (s_state != BANDIT_STORAGE_READY || !s_log_file) { + xSemaphoreGive(s_mutex); return; } @@ -282,47 +496,307 @@ void bandit_storage_log_record(const bandit_scan_record_t *record, void *ctx) ) >= 0; if (!ok) { - set_error("TF write failed"); + set_error_locked("TF write failed"); } + + xSemaphoreGive(s_mutex); } void bandit_storage_finish_scan(uint32_t generation) { (void)generation; + if (!s_mutex) { + return; + } + + xSemaphoreTake(s_mutex, portMAX_DELAY); + if (s_state != BANDIT_STORAGE_READY || !s_log_file) { + xSemaphoreGive(s_mutex); return; } s_scans_since_flush++; if (s_scans_since_flush < BANDIT_FLUSH_SCAN_INTERVAL) { + xSemaphoreGive(s_mutex); return; } - if (flush_log() != ESP_OK) { - set_error("TF flush failed"); + if (flush_log_locked() != ESP_OK) { + set_error_locked("TF flush failed"); + xSemaphoreGive(s_mutex); return; } s_scans_since_flush = 0; + xSemaphoreGive(s_mutex); } bandit_storage_state_t bandit_storage_get_state(void) { - return s_state; + if (!s_mutex) { + return s_state; + } + + xSemaphoreTake(s_mutex, portMAX_DELAY); + bandit_storage_state_t state = s_state; + xSemaphoreGive(s_mutex); + return state; +} + +esp_err_t bandit_storage_get_session_path(char *path, size_t path_size) +{ + if (!path || path_size == 0) { + return ESP_ERR_INVALID_ARG; + } + + if (!s_mutex) { + path[0] = '\0'; + return ESP_ERR_INVALID_STATE; + } + + xSemaphoreTake(s_mutex, portMAX_DELAY); + int written = snprintf(path, path_size, "%s", s_session_path); + xSemaphoreGive(s_mutex); + + if (written < 0 || written >= (int)path_size) { + return ESP_ERR_INVALID_SIZE; + } + + return path[0] ? ESP_OK : ESP_ERR_INVALID_STATE; +} + +static int compare_session_desc(const void *left, const void *right) +{ + const bandit_storage_session_t *a = left; + const bandit_storage_session_t *b = right; + + if (a->index < b->index) { + return 1; + } + if (a->index > b->index) { + return -1; + } + return 0; +} + +esp_err_t bandit_storage_list_sessions( + bandit_storage_session_t *sessions, + size_t capacity, + size_t *count +) +{ + if (!sessions || capacity == 0 || !count || !s_mutex) { + return ESP_ERR_INVALID_ARG; + } + + *count = 0; + xSemaphoreTake(s_mutex, portMAX_DELAY); + + if (!s_mounted || + s_state != BANDIT_STORAGE_READY || + s_unmount_pending) { + xSemaphoreGive(s_mutex); + return ESP_ERR_INVALID_STATE; + } + + DIR *dir = opendir(BANDIT_LOG_DIR); + if (!dir) { + ESP_LOGW(TAG, "opendir %s failed: %s", BANDIT_LOG_DIR, strerror(errno)); + xSemaphoreGive(s_mutex); + return ESP_FAIL; + } + + struct dirent *entry; + while ((entry = readdir(dir)) != NULL) { + unsigned index = 0; + if (!parse_session_filename(entry->d_name, &index)) { + continue; + } + + bandit_storage_session_t candidate = { + .index = index, + .size_bytes = 0, + .active = false, + }; + + if (*count < capacity) { + sessions[(*count)++] = candidate; + continue; + } + + size_t oldest = 0; + for (size_t i = 1; i < capacity; i++) { + if (sessions[i].index < sessions[oldest].index) { + oldest = i; + } + } + + if (candidate.index > sessions[oldest].index) { + sessions[oldest] = candidate; + } + } + closedir(dir); + + size_t valid_count = 0; + for (size_t i = 0; i < *count; i++) { + char path[BANDIT_SESSION_PATH_MAX]; + if (bandit_storage_session_path( + sessions[i].index, + path, + sizeof(path) + ) != ESP_OK) { + continue; + } + + struct stat st; + if (stat(path, &st) != 0 || st.st_size < 0) { + continue; + } + + sessions[valid_count] = sessions[i]; + sessions[valid_count].size_bytes = (size_t)st.st_size; + sessions[valid_count].active = + strcmp(path, s_session_path) == 0; + valid_count++; + } + + *count = valid_count; + qsort(sessions, *count, sizeof(*sessions), compare_session_desc); + xSemaphoreGive(s_mutex); + return ESP_OK; +} + +esp_err_t bandit_storage_open_completed_session( + unsigned index, + bandit_storage_reader_t **reader, + size_t *size_bytes +) +{ + if (!reader || !size_bytes || !s_mutex) { + return ESP_ERR_INVALID_ARG; + } + + *reader = NULL; + *size_bytes = 0; + + bandit_storage_reader_t *candidate = calloc(1, sizeof(*candidate)); + if (!candidate) { + return ESP_ERR_NO_MEM; + } + + char path[BANDIT_SESSION_PATH_MAX]; + esp_err_t ret = bandit_storage_session_path(index, path, sizeof(path)); + if (ret != ESP_OK) { + free(candidate); + return ret; + } + + xSemaphoreTake(s_mutex, portMAX_DELAY); + + if (!s_mounted || + s_state != BANDIT_STORAGE_READY || + s_unmount_pending) { + xSemaphoreGive(s_mutex); + free(candidate); + return ESP_ERR_INVALID_STATE; + } + + if (strcmp(path, s_session_path) == 0) { + xSemaphoreGive(s_mutex); + free(candidate); + return ESP_ERR_INVALID_STATE; + } + + FILE *file = fopen(path, "rb"); + if (!file) { + xSemaphoreGive(s_mutex); + free(candidate); + return ESP_ERR_NOT_FOUND; + } + + struct stat st; + if (fstat(fileno(file), &st) != 0 || st.st_size < 0) { + fclose(file); + xSemaphoreGive(s_mutex); + free(candidate); + return ESP_FAIL; + } + + candidate->file = file; + candidate->size_bytes = (size_t)st.st_size; + candidate->index = index; + s_reader_count++; + + xSemaphoreGive(s_mutex); + + *reader = candidate; + *size_bytes = candidate->size_bytes; + return ESP_OK; +} + +size_t bandit_storage_read_completed_session( + bandit_storage_reader_t *reader, + void *buffer, + size_t size, + bool *eof +) +{ + if (eof) { + *eof = false; + } + + if (!reader || !reader->file || !buffer || size == 0) { + return 0; + } + + size_t count = fread(buffer, 1, size, reader->file); + if (eof) { + *eof = feof(reader->file) != 0; + } + return count; } -const char *bandit_storage_get_session_path(void) +void bandit_storage_close_completed_session(bandit_storage_reader_t *reader) { - return s_session_path; + if (!reader) { + return; + } + + if (reader->file) { + (void)fclose(reader->file); + reader->file = NULL; + } + + if (s_mutex) { + xSemaphoreTake(s_mutex, portMAX_DELAY); + + if (s_reader_count > 0) { + s_reader_count--; + } + complete_pending_unmount_locked(); + + xSemaphoreGive(s_mutex); + } + + free(reader); } void bandit_storage_deinit(void) { + if (!s_mutex) { + return; + } + + xSemaphoreTake(s_mutex, portMAX_DELAY); + if (s_log_file) { - (void)flush_log(); + (void)flush_log_locked(); } - teardown_storage(BANDIT_STORAGE_NO_CARD); + teardown_storage_locked(BANDIT_STORAGE_NO_CARD); s_next_retry_ms = 0; + + xSemaphoreGive(s_mutex); } diff --git a/main/bandit_storage.h b/main/bandit_storage.h index 415d495..c76821d 100644 --- a/main/bandit_storage.h +++ b/main/bandit_storage.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include "esp_err.h" @@ -12,10 +14,43 @@ typedef enum { BANDIT_STORAGE_ERROR, } bandit_storage_state_t; +typedef struct { + unsigned index; + size_t size_bytes; + bool active; +} bandit_storage_session_t; + +typedef struct bandit_storage_reader bandit_storage_reader_t; + esp_err_t bandit_storage_init(void); void bandit_storage_service(void); void bandit_storage_log_record(const bandit_scan_record_t *record, void *ctx); void bandit_storage_finish_scan(uint32_t generation); bandit_storage_state_t bandit_storage_get_state(void); -const char *bandit_storage_get_session_path(void); +esp_err_t bandit_storage_get_session_path( + char *path, + size_t path_size +); +esp_err_t bandit_storage_list_sessions( + bandit_storage_session_t *sessions, + size_t capacity, + size_t *count +); +esp_err_t bandit_storage_session_path( + unsigned index, + char *path, + size_t path_size +); +esp_err_t bandit_storage_open_completed_session( + unsigned index, + bandit_storage_reader_t **reader, + size_t *size_bytes +); +size_t bandit_storage_read_completed_session( + bandit_storage_reader_t *reader, + void *buffer, + size_t size, + bool *eof +); +void bandit_storage_close_completed_session(bandit_storage_reader_t *reader); void bandit_storage_deinit(void); diff --git a/main/bandit_uplink.c b/main/bandit_uplink.c index 0b18904..83d92bc 100644 --- a/main/bandit_uplink.c +++ b/main/bandit_uplink.c @@ -1,12 +1,15 @@ #include "bandit_uplink.h" +#include #include #include #include #include #include +#include #include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" #include "freertos/task.h" #include "esp_event.h" @@ -26,16 +29,29 @@ #define BANDIT_UPLINK_NVS_SSID "ssid" #define BANDIT_UPLINK_NVS_PASSWORD "password" #define BANDIT_UPLINK_RETRY_MS 15000 -#define BANDIT_UPLINK_HTTP_STACK 8192 +#define BANDIT_UPLINK_ASSOC_TIMEOUT_MS 15000 +#define BANDIT_UPLINK_DHCP_TIMEOUT_MS 10000 +#define BANDIT_UPLINK_HTTP_STACK 4096 +#define BANDIT_UPLINK_AP_CACHE_MAX 64 +#define BANDIT_UPLINK_SESSION_LIST_MAX 32 +#define BANDIT_UPLINK_DOWNLOAD_BUFFER 2048 static const char *TAG = "bandit_uplink"; static volatile bool s_enabled; static bool s_connected; +static bool s_connecting; +static bool s_associated; +static bool s_first_attempt_resolved; +static bool s_services_pending; +static bool s_state_dirty; +static bool s_scan_waiting; static char s_ssid[33]; static char s_ip[16]; static httpd_handle_t s_server; static int64_t s_next_retry_ms; +static int64_t s_connect_deadline_ms; +static bool s_connect_radio_owned; static esp_event_handler_instance_t s_wifi_handler; static esp_event_handler_instance_t s_ip_handler; static bool s_wifi_handler_registered; @@ -46,6 +62,16 @@ static portMUX_TYPE s_state_mux = portMUX_INITIALIZER_UNLOCKED; static bandit_scan_snapshot_t s_snapshot; static bool s_have_snapshot; +static SemaphoreHandle_t s_ap_mutex; +static SemaphoreHandle_t s_radio_mutex; +static bandit_scan_record_t s_work_aps[BANDIT_UPLINK_AP_CACHE_MAX]; +static size_t s_work_ap_count; +static size_t s_work_seen; +static bandit_scan_record_t s_published_aps[BANDIT_UPLINK_AP_CACHE_MAX]; +static size_t s_published_ap_count; +static uint32_t s_published_generation; +static bool s_published_truncated; + static const char s_status_page[] = "" "" @@ -53,10 +79,18 @@ static const char s_status_page[] = "C5 Bandit" "" "

C5 Bandit

loading...
" "
Uplink
loading...
" @@ -66,18 +100,50 @@ static const char s_status_page[] = "" "
Strongest
--
" "
waiting for scan...
" + "
Latest scan APs
waiting for scan...
" + "
" + "
SSIDBSSIDBandCHRSSIAuth
" + "
TF sessions
Newest 32 sessions; active log may lag by the flush interval.
" + "
loading...
" ""; static void json_escape(const char *src, char *dst, size_t dst_size) @@ -106,6 +172,20 @@ static void json_escape(const char *src, char *dst, size_t dst_size) dst[out] = '\0'; } +static int compare_ap_rssi_desc(const void *left, const void *right) +{ + const bandit_scan_record_t *a = left; + const bandit_scan_record_t *b = right; + + if (a->rssi > b->rssi) { + return -1; + } + if (a->rssi < b->rssi) { + return 1; + } + return strcmp(a->ssid, b->ssid); +} + static const char *storage_state_name(void) { switch (bandit_storage_get_state()) { @@ -138,8 +218,8 @@ static esp_err_t status_json_handler(httpd_req_t *req) snapshot = s_snapshot; have_snapshot = s_have_snapshot; connected = s_connected; - snprintf(ssid, sizeof(ssid), "%s", s_ssid); - snprintf(ip, sizeof(ip), "%s", s_ip); + memcpy(ssid, s_ssid, sizeof(ssid)); + memcpy(ip, s_ip, sizeof(ip)); portEXIT_CRITICAL(&s_state_mux); char escaped_ssid[70]; @@ -207,6 +287,346 @@ static esp_err_t status_json_handler(httpd_req_t *req) return ret; } +static esp_err_t aps_json_handler(httpd_req_t *req) +{ + if (!s_ap_mutex) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "AP cache unavailable"); + return ESP_ERR_INVALID_STATE; + } + + bandit_scan_record_t *records = malloc( + BANDIT_UPLINK_AP_CACHE_MAX * sizeof(*records) + ); + if (!records) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory"); + return ESP_ERR_NO_MEM; + } + + size_t count = 0; + uint32_t generation = 0; + bool truncated = false; + + if (xSemaphoreTake(s_ap_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) { + free(records); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "AP cache busy"); + return ESP_ERR_TIMEOUT; + } + + count = s_published_ap_count; + generation = s_published_generation; + truncated = s_published_truncated; + memcpy(records, s_published_aps, count * sizeof(*records)); + xSemaphoreGive(s_ap_mutex); + + httpd_resp_set_type(req, "application/json; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + + char chunk[384]; + int written = snprintf( + chunk, + sizeof(chunk), + "{\"scan\":%lu,\"truncated\":%s,\"aps\":[", + (unsigned long)generation, + truncated ? "true" : "false" + ); + if (written < 0 || written >= (int)sizeof(chunk)) { + free(records); + return ESP_FAIL; + } + + esp_err_t ret = httpd_resp_send_chunk(req, chunk, written); + for (size_t i = 0; ret == ESP_OK && i < count; i++) { + const bandit_scan_record_t *record = &records[i]; + char escaped_ssid[70]; + char escaped_auth[30]; + char bssid[18]; + + json_escape(record->ssid, escaped_ssid, sizeof(escaped_ssid)); + json_escape(record->auth, escaped_auth, sizeof(escaped_auth)); + snprintf( + bssid, + sizeof(bssid), + "%02X:%02X:%02X:%02X:%02X:%02X", + record->bssid[0], + record->bssid[1], + record->bssid[2], + record->bssid[3], + record->bssid[4], + record->bssid[5] + ); + + written = snprintf( + chunk, + sizeof(chunk), + "%s{\"ssid\":\"%s\",\"bssid\":\"%s\",\"rssi\":%d," + "\"channel\":%u,\"band\":\"%s\",\"auth\":\"%s\",\"hidden\":%s}", + i ? "," : "", + escaped_ssid, + bssid, + record->rssi, + record->channel, + record->channel > 14 ? "5 GHz" : "2.4 GHz", + escaped_auth, + record->hidden ? "true" : "false" + ); + + if (written < 0 || written >= (int)sizeof(chunk)) { + ret = ESP_FAIL; + break; + } + + ret = httpd_resp_send_chunk(req, chunk, written); + } + + if (ret == ESP_OK) { + ret = httpd_resp_send_chunk(req, "]}", 2); + } + if (ret == ESP_OK) { + ret = httpd_resp_send_chunk(req, NULL, 0); + } + + free(records); + return ret; +} + +static esp_err_t logs_json_handler(httpd_req_t *req) +{ + bandit_storage_session_t *sessions = calloc( + BANDIT_UPLINK_SESSION_LIST_MAX, + sizeof(*sessions) + ); + if (!sessions) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory"); + return ESP_ERR_NO_MEM; + } + + size_t count = 0; + esp_err_t list_ret = bandit_storage_list_sessions( + sessions, + BANDIT_UPLINK_SESSION_LIST_MAX, + &count + ); + + httpd_resp_set_type(req, "application/json; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + + if (list_ret != ESP_OK) { + free(sessions); + return httpd_resp_sendstr( + req, + "{\"available\":false,\"sessions\":[]}" + ); + } + + esp_err_t ret = httpd_resp_send_chunk( + req, + "{\"available\":true,\"sessions\":[", + HTTPD_RESP_USE_STRLEN + ); + + char chunk[192]; + for (size_t i = 0; ret == ESP_OK && i < count; i++) { + int written = snprintf( + chunk, + sizeof(chunk), + "%s{\"index\":%u,\"name\":\"session-%04u.csv\"," + "\"bytes\":%zu,\"active\":%s}", + i ? "," : "", + sessions[i].index, + sessions[i].index, + sessions[i].size_bytes, + sessions[i].active ? "true" : "false" + ); + + if (written < 0 || written >= (int)sizeof(chunk)) { + ret = ESP_FAIL; + break; + } + + ret = httpd_resp_send_chunk(req, chunk, written); + } + + if (ret == ESP_OK) { + ret = httpd_resp_send_chunk(req, "]}", 2); + } + if (ret == ESP_OK) { + ret = httpd_resp_send_chunk(req, NULL, 0); + } + + free(sessions); + return ret; +} + +static esp_err_t download_handler(httpd_req_t *req) +{ + size_t query_len = httpd_req_get_url_query_len(req); + if (query_len == 0 || query_len >= 64) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing session index"); + return ESP_ERR_INVALID_ARG; + } + + char query[64]; + char value[16]; + if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK || + httpd_query_key_value(query, "session", value, sizeof(value)) != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid session query"); + return ESP_ERR_INVALID_ARG; + } + + errno = 0; + char *end = NULL; + unsigned long parsed = strtoul(value, &end, 10); + if (errno != 0 || !end || *end != '\0' || parsed == 0 || parsed > 9999) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid session index"); + return ESP_ERR_INVALID_ARG; + } + + bandit_storage_reader_t *reader = NULL; + size_t file_size = 0; + esp_err_t ret = bandit_storage_open_completed_session( + (unsigned)parsed, + &reader, + &file_size + ); + if (ret != ESP_OK) { + if (ret == ESP_ERR_NOT_FOUND) { + httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Session not found"); + } else if (ret == ESP_ERR_INVALID_STATE) { + httpd_resp_set_status(req, "409 Conflict"); + httpd_resp_sendstr(req, "Session unavailable or still active"); + } else { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Unable to open session"); + } + return ret; + } + + char *buffer = malloc(BANDIT_UPLINK_DOWNLOAD_BUFFER); + if (!buffer) { + bandit_storage_close_completed_session(reader); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory"); + return ESP_ERR_NO_MEM; + } + + bool connecting; + bool connected; + bool scan_waiting; + portENTER_CRITICAL(&s_state_mux); + connecting = s_connecting; + connected = s_connected; + scan_waiting = s_scan_waiting; + portEXIT_CRITICAL(&s_state_mux); + + if ((connecting && !connected) || scan_waiting) { + bandit_storage_close_completed_session(reader); + free(buffer); + httpd_resp_set_status(req, "503 Service Unavailable"); + return httpd_resp_sendstr(req, "Scout radio busy; retry shortly"); + } + + if (s_radio_mutex && + xSemaphoreTake(s_radio_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) { + bandit_storage_close_completed_session(reader); + free(buffer); + httpd_resp_set_status(req, "503 Service Unavailable"); + return httpd_resp_sendstr(req, "Radio busy; retry shortly"); + } + + portENTER_CRITICAL(&s_state_mux); + connecting = s_connecting; + connected = s_connected; + scan_waiting = s_scan_waiting; + portEXIT_CRITICAL(&s_state_mux); + + if ((connecting && !connected) || scan_waiting) { + if (s_radio_mutex) { + xSemaphoreGive(s_radio_mutex); + } + bandit_storage_close_completed_session(reader); + free(buffer); + httpd_resp_set_status(req, "503 Service Unavailable"); + return httpd_resp_sendstr(req, "Scout scan pending; retry shortly"); + } + + char disposition[64]; + char size_header[24]; + snprintf( + disposition, + sizeof(disposition), + "attachment; filename=\"session-%04lu.csv\"", + parsed + ); + snprintf(size_header, sizeof(size_header), "%zu", file_size); + + httpd_resp_set_type(req, "text/csv; charset=utf-8"); + httpd_resp_set_hdr(req, "Content-Disposition", disposition); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + httpd_resp_set_hdr(req, "X-BandiC5-File-Size", size_header); + + size_t total_sent = 0; + while (total_sent < file_size) { + if (!s_enabled) { + ESP_LOGI(TAG, "download cancelled because uplink is stopping"); + ret = ESP_ERR_INVALID_STATE; + break; + } + + size_t remaining = file_size - total_sent; + size_t request = remaining < BANDIT_UPLINK_DOWNLOAD_BUFFER + ? remaining + : BANDIT_UPLINK_DOWNLOAD_BUFFER; + + bool eof = false; + size_t read_count = bandit_storage_read_completed_session( + reader, + buffer, + request, + &eof + ); + + if (read_count == 0) { + ESP_LOGW( + TAG, + "short read while downloading session=%04lu eof=%d", + parsed, + eof + ); + ret = ESP_FAIL; + break; + } + + ret = httpd_resp_send_chunk(req, buffer, read_count); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "send failed while downloading session=%04lu", parsed); + break; + } + + total_sent += read_count; + } + + if (ret == ESP_OK && total_sent == file_size) { + ret = httpd_resp_send_chunk(req, NULL, 0); + } else if (ret == ESP_OK) { + ret = ESP_FAIL; + } + + bandit_storage_close_completed_session(reader); + if (s_radio_mutex) { + xSemaphoreGive(s_radio_mutex); + } + free(buffer); + + ESP_LOGI( + TAG, + "download session=%04lu bytes=%zu/%zu result=%s", + parsed, + total_sent, + file_size, + esp_err_to_name(ret) + ); + + return ret; +} + static esp_err_t start_status_server(void) { if (s_server) { @@ -215,7 +635,13 @@ static esp_err_t start_status_server(void) httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.stack_size = BANDIT_UPLINK_HTTP_STACK; - config.max_uri_handlers = 3; + config.max_uri_handlers = 5; + config.send_wait_timeout = 20; + config.lru_purge_enable = true; + config.keep_alive_enable = true; + config.keep_alive_idle = 10; + config.keep_alive_interval = 3; + config.keep_alive_count = 3; esp_err_t ret = httpd_start(&s_server, &config); if (ret != ESP_OK) { @@ -234,11 +660,35 @@ static esp_err_t start_status_server(void) .method = HTTP_GET, .handler = status_json_handler, }; + httpd_uri_t aps = { + .uri = "/aps.json", + .method = HTTP_GET, + .handler = aps_json_handler, + }; + httpd_uri_t logs = { + .uri = "/logs.json", + .method = HTTP_GET, + .handler = logs_json_handler, + }; + httpd_uri_t download = { + .uri = "/download", + .method = HTTP_GET, + .handler = download_handler, + }; ret = httpd_register_uri_handler(s_server, &root); if (ret == ESP_OK) { ret = httpd_register_uri_handler(s_server, &status); } + if (ret == ESP_OK) { + ret = httpd_register_uri_handler(s_server, &aps); + } + if (ret == ESP_OK) { + ret = httpd_register_uri_handler(s_server, &logs); + } + if (ret == ESP_OK) { + ret = httpd_register_uri_handler(s_server, &download); + } if (ret != ESP_OK) { ESP_LOGE(TAG, "status route registration failed: %s", esp_err_to_name(ret)); @@ -265,15 +715,32 @@ static void wifi_event_handler( return; } - if (event_id == WIFI_EVENT_STA_DISCONNECTED) { + const int64_t current_ms = esp_timer_get_time() / 1000; + + if (event_id == WIFI_EVENT_STA_CONNECTED) { portENTER_CRITICAL(&s_state_mux); - s_connected = false; - s_ip[0] = '\0'; + s_associated = true; + s_connecting = true; + s_connect_deadline_ms = current_ms + BANDIT_UPLINK_DHCP_TIMEOUT_MS; portEXIT_CRITICAL(&s_state_mux); - bandit_ui_set_uplink_state(true, false); - bandit_ui_set_uplink_address(NULL); - ESP_LOGW(TAG, "uplink disconnected; reconnect scheduled between scans"); + return; + } + + if (event_id != WIFI_EVENT_STA_DISCONNECTED) { + return; } + + portENTER_CRITICAL(&s_state_mux); + s_connected = false; + s_connecting = false; + s_associated = false; + s_first_attempt_resolved = true; + s_services_pending = false; + s_state_dirty = true; + s_ip[0] = '\0'; + s_connect_deadline_ms = 0; + s_next_retry_ms = current_ms + BANDIT_UPLINK_RETRY_MS; + portEXIT_CRITICAL(&s_state_mux); } static void ip_event_handler( @@ -286,38 +753,41 @@ static void ip_event_handler( (void)arg; (void)event_base; - if (!s_enabled || event_id != IP_EVENT_STA_GOT_IP) { + if (!s_enabled) { return; } - const ip_event_got_ip_t *event = (const ip_event_got_ip_t *)event_data; - char ip[16]; - snprintf(ip, sizeof(ip), IPSTR, IP2STR(&event->ip_info.ip)); - - portENTER_CRITICAL(&s_state_mux); - s_connected = true; - snprintf(s_ip, sizeof(s_ip), "%s", ip); - portEXIT_CRITICAL(&s_state_mux); + if (event_id == IP_EVENT_STA_GOT_IP) { + const ip_event_got_ip_t *event = (const ip_event_got_ip_t *)event_data; + char ip[16]; + snprintf(ip, sizeof(ip), IPSTR, IP2STR(&event->ip_info.ip)); - bandit_ui_set_uplink_state(true, true); - bandit_ui_set_uplink_address(ip); - ESP_LOGI(TAG, "uplink connected: SSID=%s IP=%s hostname=bandic5", s_ssid, ip); + portENTER_CRITICAL(&s_state_mux); + s_connected = true; + s_connecting = false; + s_associated = true; + s_first_attempt_resolved = true; + s_services_pending = true; + s_state_dirty = true; + s_connect_deadline_ms = 0; + memcpy(s_ip, ip, sizeof(s_ip)); + portEXIT_CRITICAL(&s_state_mux); + return; + } - esp_err_t ret = start_status_server(); - if (ret == ESP_OK) { - if (!s_mdns_started) { - esp_err_t mdns_ret = mdns_init(); - if (mdns_ret == ESP_OK) { - (void)mdns_hostname_set("bandic5"); - (void)mdns_instance_name_set("C5 Bandit"); - (void)mdns_service_add(NULL, "_http", "_tcp", 80, NULL, 0); - s_mdns_started = true; - } else { - ESP_LOGW(TAG, "mDNS init failed: %s", esp_err_to_name(mdns_ret)); - } - } + if (event_id == IP_EVENT_STA_LOST_IP) { + const int64_t current_ms = esp_timer_get_time() / 1000; - ESP_LOGI(TAG, "status page ready: http://%s/ (try http://bandic5.local/)", ip); + portENTER_CRITICAL(&s_state_mux); + s_connected = false; + s_connecting = true; + s_associated = true; + s_first_attempt_resolved = true; + s_services_pending = false; + s_state_dirty = true; + s_ip[0] = '\0'; + s_connect_deadline_ms = current_ms + BANDIT_UPLINK_DHCP_TIMEOUT_MS; + portEXIT_CRITICAL(&s_state_mux); } } @@ -411,11 +881,28 @@ esp_err_t bandit_uplink_clear_credentials(void) esp_err_t bandit_uplink_init(void) { + if (!s_ap_mutex) { + s_ap_mutex = xSemaphoreCreateMutex(); + if (!s_ap_mutex) { + return ESP_ERR_NO_MEM; + } + } + + if (!s_radio_mutex) { + s_radio_mutex = xSemaphoreCreateMutex(); + if (!s_radio_mutex) { + return ESP_ERR_NO_MEM; + } + } + char password[64] = {0}; char ssid[33] = {0}; esp_err_t ret = load_credentials(ssid, password); if (ret == ESP_ERR_NVS_NOT_FOUND || (ret == ESP_OK && ssid[0] == '\0')) { + portENTER_CRITICAL(&s_state_mux); + s_first_attempt_resolved = true; + portEXIT_CRITICAL(&s_state_mux); bandit_ui_set_uplink_state(false, false); ESP_LOGI(TAG, "uplink not configured"); return ESP_OK; @@ -442,7 +929,7 @@ esp_err_t bandit_uplink_init(void) ret = esp_event_handler_instance_register( WIFI_EVENT, - WIFI_EVENT_STA_DISCONNECTED, + ESP_EVENT_ANY_ID, wifi_event_handler, NULL, &s_wifi_handler @@ -454,19 +941,26 @@ esp_err_t bandit_uplink_init(void) ret = esp_event_handler_instance_register( IP_EVENT, - IP_EVENT_STA_GOT_IP, + ESP_EVENT_ANY_ID, ip_event_handler, NULL, &s_ip_handler ); if (ret != ESP_OK) { - esp_event_handler_instance_unregister(WIFI_EVENT, WIFI_EVENT_STA_DISCONNECTED, s_wifi_handler); + esp_event_handler_instance_unregister( + WIFI_EVENT, + ESP_EVENT_ANY_ID, + s_wifi_handler + ); s_wifi_handler_registered = false; return ret; } s_ip_handler_registered = true; - ret = esp_wifi_set_config(WIFI_IF_STA, &config); + ret = esp_wifi_set_storage(WIFI_STORAGE_RAM); + if (ret == ESP_OK) { + ret = esp_wifi_set_config(WIFI_IF_STA, &config); + } if (ret != ESP_OK) { bandit_uplink_stop(); return ret; @@ -476,12 +970,19 @@ esp_err_t bandit_uplink_init(void) snprintf(s_ssid, sizeof(s_ssid), "%s", ssid); s_ip[0] = '\0'; s_connected = false; + s_connecting = false; + s_associated = false; + s_first_attempt_resolved = false; + s_services_pending = false; + s_state_dirty = true; + s_scan_waiting = false; + s_next_retry_ms = 0; + s_connect_deadline_ms = 0; portEXIT_CRITICAL(&s_state_mux); s_enabled = true; bandit_ui_set_uplink_state(true, false); - - s_next_retry_ms = 0; + bandit_ui_set_uplink_address(NULL); ESP_LOGI(TAG, "uplink enabled for SSID=%s; first connect follows initial RF scan", s_ssid); return ESP_OK; @@ -493,50 +994,169 @@ void bandit_uplink_service(void) return; } + const int64_t current_ms = esp_timer_get_time() / 1000; bool connected; + bool connecting; + bool associated; + bool state_dirty; + bool services_pending; + bool scan_waiting; + int64_t next_retry_ms; + int64_t connect_deadline_ms; + char ssid[33]; + char ip[16]; + portENTER_CRITICAL(&s_state_mux); connected = s_connected; + connecting = s_connecting; + associated = s_associated; + state_dirty = s_state_dirty; + services_pending = s_services_pending; + scan_waiting = s_scan_waiting; + next_retry_ms = s_next_retry_ms; + connect_deadline_ms = s_connect_deadline_ms; + memcpy(ssid, s_ssid, sizeof(ssid)); + memcpy(ip, s_ip, sizeof(ip)); + s_state_dirty = false; + s_services_pending = false; portEXIT_CRITICAL(&s_state_mux); + if (s_connect_radio_owned && (!connecting || connected)) { + xSemaphoreGive(s_radio_mutex); + s_connect_radio_owned = false; + } + + if (state_dirty) { + bandit_ui_set_uplink_state(true, connected); + bandit_ui_set_uplink_address(connected ? ip : NULL); + + if (connected) { + ESP_LOGI(TAG, "uplink connected: SSID=%s IP=%s hostname=bandic5", ssid, ip); + } else if (connecting) { + ESP_LOGI(TAG, "uplink connection in progress"); + } else { + ESP_LOGW(TAG, "uplink disconnected; reconnect scheduled between scans"); + } + } + if (connected) { + if (services_pending) { + esp_err_t ret = start_status_server(); + if (ret == ESP_OK && !s_mdns_started) { + esp_err_t mdns_ret = mdns_init(); + if (mdns_ret == ESP_OK) { + (void)mdns_hostname_set("bandic5"); + (void)mdns_instance_name_set("C5 Bandit"); + (void)mdns_service_add(NULL, "_http", "_tcp", 80, NULL, 0); + s_mdns_started = true; + } else { + ESP_LOGW(TAG, "mDNS init failed: %s", esp_err_to_name(mdns_ret)); + } + } + + if (ret == ESP_OK) { + ESP_LOGI(TAG, "status page ready: http://%s/ (try http://bandic5.local/)", ip); + } else { + portENTER_CRITICAL(&s_state_mux); + s_services_pending = true; + portEXIT_CRITICAL(&s_state_mux); + } + } return; } - const int64_t current_ms = esp_timer_get_time() / 1000; - if (s_next_retry_ms != 0 && current_ms < s_next_retry_ms) { + if (connecting) { + if (!s_connect_radio_owned) { + if (!s_radio_mutex || + xSemaphoreTake(s_radio_mutex, 0) != pdTRUE) { + return; + } + s_connect_radio_owned = true; + } + + if (current_ms < connect_deadline_ms) { + return; + } + + ESP_LOGW( + TAG, + "uplink %s timed out; returning radio to scout", + associated ? "DHCP" : "association" + ); + (void)esp_wifi_disconnect(); + + portENTER_CRITICAL(&s_state_mux); + s_connecting = false; + s_associated = false; + s_first_attempt_resolved = true; + s_state_dirty = true; + s_next_retry_ms = current_ms + BANDIT_UPLINK_RETRY_MS; + portEXIT_CRITICAL(&s_state_mux); + + if (s_connect_radio_owned) { + xSemaphoreGive(s_radio_mutex); + s_connect_radio_owned = false; + } return; } - esp_err_t ret = esp_wifi_connect(); - if (ret != ESP_OK && ret != ESP_ERR_WIFI_CONN && ret != ESP_ERR_WIFI_STATE) { - ESP_LOGD(TAG, "uplink connect attempt: %s", esp_err_to_name(ret)); + if (next_retry_ms != 0 && current_ms < next_retry_ms) { + return; } - s_next_retry_ms = current_ms + BANDIT_UPLINK_RETRY_MS; -} + // Once a scan has been deferred, let it run before beginning another + // connection attempt. + if (scan_waiting) { + return; + } -void bandit_uplink_stop(void) -{ - s_enabled = false; - s_next_retry_ms = 0; + if (!s_radio_mutex || + xSemaphoreTake(s_radio_mutex, 0) != pdTRUE) { + return; + } + s_connect_radio_owned = true; - if (s_server) { - httpd_stop(s_server); - s_server = NULL; + // Publish CONNECTING before calling into the asynchronous Wi-Fi driver so a + // very fast GOT_IP event cannot be overwritten by the caller afterwards. + portENTER_CRITICAL(&s_state_mux); + s_connecting = true; + s_associated = false; + s_connect_deadline_ms = current_ms + BANDIT_UPLINK_ASSOC_TIMEOUT_MS; + portEXIT_CRITICAL(&s_state_mux); + + esp_err_t ret = esp_wifi_connect(); + if (ret == ESP_OK || ret == ESP_ERR_WIFI_STATE) { + return; } - if (s_mdns_started) { - mdns_free(); - s_mdns_started = false; + ESP_LOGD(TAG, "uplink connect attempt failed immediately: %s", esp_err_to_name(ret)); + + bool connected_after_call; + portENTER_CRITICAL(&s_state_mux); + connected_after_call = s_connected; + if (!connected_after_call) { + s_connecting = false; + s_associated = false; + s_first_attempt_resolved = true; + s_connect_deadline_ms = 0; + s_next_retry_ms = current_ms + BANDIT_UPLINK_RETRY_MS; + } + portEXIT_CRITICAL(&s_state_mux); + + if (!connected_after_call) { + xSemaphoreGive(s_radio_mutex); + s_connect_radio_owned = false; } +} - bandit_ui_set_uplink_state(false, false); - bandit_ui_set_uplink_address(NULL); +void bandit_uplink_stop(void) +{ + s_enabled = false; if (s_wifi_handler_registered) { esp_event_handler_instance_unregister( WIFI_EVENT, - WIFI_EVENT_STA_DISCONNECTED, + ESP_EVENT_ANY_ID, s_wifi_handler ); s_wifi_handler_registered = false; @@ -545,24 +1165,160 @@ void bandit_uplink_stop(void) if (s_ip_handler_registered) { esp_event_handler_instance_unregister( IP_EVENT, - IP_EVENT_STA_GOT_IP, + ESP_EVENT_ANY_ID, s_ip_handler ); s_ip_handler_registered = false; } + if (s_connect_radio_owned && s_radio_mutex) { + xSemaphoreGive(s_radio_mutex); + s_connect_radio_owned = false; + } + + if (s_server) { + httpd_stop(s_server); + s_server = NULL; + } + + if (s_mdns_started) { + mdns_free(); + s_mdns_started = false; + } + + bandit_ui_set_uplink_state(false, false); + bandit_ui_set_uplink_address(NULL); + portENTER_CRITICAL(&s_state_mux); s_connected = false; + s_connecting = false; + s_associated = false; + s_first_attempt_resolved = true; + s_services_pending = false; + s_state_dirty = false; + s_scan_waiting = false; + s_next_retry_ms = 0; + s_connect_deadline_ms = 0; s_ip[0] = '\0'; portEXIT_CRITICAL(&s_state_mux); } +bool bandit_uplink_try_begin_scan(void) +{ + bool connecting = false; + bool connected = false; + + portENTER_CRITICAL(&s_state_mux); + connecting = s_connecting; + connected = s_connected; + portEXIT_CRITICAL(&s_state_mux); + + if (connecting && !connected) { + portENTER_CRITICAL(&s_state_mux); + s_scan_waiting = true; + portEXIT_CRITICAL(&s_state_mux); + return false; + } + + if (s_radio_mutex && + xSemaphoreTake(s_radio_mutex, 0) != pdTRUE) { + portENTER_CRITICAL(&s_state_mux); + s_scan_waiting = true; + portEXIT_CRITICAL(&s_state_mux); + return false; + } + + portENTER_CRITICAL(&s_state_mux); + connecting = s_connecting; + connected = s_connected; + if (!(connecting && !connected)) { + s_scan_waiting = false; + } + portEXIT_CRITICAL(&s_state_mux); + + if (connecting && !connected) { + if (s_radio_mutex) { + xSemaphoreGive(s_radio_mutex); + } + portENTER_CRITICAL(&s_state_mux); + s_scan_waiting = true; + portEXIT_CRITICAL(&s_state_mux); + return false; + } + + s_work_ap_count = 0; + s_work_seen = 0; + return true; +} + +void bandit_uplink_end_scan(void) +{ + if (s_radio_mutex) { + xSemaphoreGive(s_radio_mutex); + } +} + +bool bandit_uplink_first_attempt_resolved(void) +{ + portENTER_CRITICAL(&s_state_mux); + bool resolved = !s_enabled || s_first_attempt_resolved; + portEXIT_CRITICAL(&s_state_mux); + return resolved; +} + +void bandit_uplink_observe_record(const bandit_scan_record_t *record) +{ + if (!record) { + return; + } + + s_work_seen++; + + if (s_work_ap_count < BANDIT_UPLINK_AP_CACHE_MAX) { + s_work_aps[s_work_ap_count++] = *record; + return; + } + + // Keep the strongest bounded set if a dense environment exceeds the + // browser cache capacity. + size_t weakest = 0; + for (size_t i = 1; i < s_work_ap_count; i++) { + if (s_work_aps[i].rssi < s_work_aps[weakest].rssi) { + weakest = i; + } + } + + if (record->rssi > s_work_aps[weakest].rssi) { + s_work_aps[weakest] = *record; + } +} + void bandit_uplink_publish_snapshot(const bandit_scan_snapshot_t *snapshot) { if (!snapshot) { return; } + qsort( + s_work_aps, + s_work_ap_count, + sizeof(s_work_aps[0]), + compare_ap_rssi_desc + ); + + if (s_ap_mutex && + xSemaphoreTake(s_ap_mutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + s_published_ap_count = s_work_ap_count; + s_published_generation = snapshot->generation; + s_published_truncated = s_work_seen > s_work_ap_count; + memcpy( + s_published_aps, + s_work_aps, + s_work_ap_count * sizeof(s_work_aps[0]) + ); + xSemaphoreGive(s_ap_mutex); + } + portENTER_CRITICAL(&s_state_mux); s_snapshot = *snapshot; s_have_snapshot = true; diff --git a/main/bandit_uplink.h b/main/bandit_uplink.h index aeac38c..74a5028 100644 --- a/main/bandit_uplink.h +++ b/main/bandit_uplink.h @@ -9,6 +9,10 @@ esp_err_t bandit_uplink_init(void); void bandit_uplink_service(void); void bandit_uplink_stop(void); +bool bandit_uplink_try_begin_scan(void); +void bandit_uplink_end_scan(void); +bool bandit_uplink_first_attempt_resolved(void); +void bandit_uplink_observe_record(const bandit_scan_record_t *record); void bandit_uplink_publish_snapshot(const bandit_scan_snapshot_t *snapshot); bool bandit_uplink_get_configured_ssid(char out_ssid[33]); diff --git a/main/bandit_version.h b/main/bandit_version.h index 711083e..4a19b0e 100644 --- a/main/bandit_version.h +++ b/main/bandit_version.h @@ -1,4 +1,4 @@ #pragma once -#define BANDIT_VERSION "v0.3.1 UPLINK UI" -#define BANDIT_VERSION_SHORT "v0.3.1" +#define BANDIT_VERSION "v0.4.6 RC" +#define BANDIT_VERSION_SHORT "v0.4.6"