Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,14 @@ consequences that matter day to day:
and in Citadel's `external_issue_number` still resolves. Numbers that were pull
requests exist as **closed placeholders** pointing at the archive — the PRs themselves
could not be recreated. See #245 for the list.
- **`src/web.cpp` holds placeholder credentials** (`REDACTED_SSID_1` and friends; they
were in `src.ino` until E4 band 3e, #274). A build from `main` compiles and **cannot
join WiFi**, which also means no `fieldcompass.local` diagnostics. Until #99 moves
credentials to runtime storage, flashing needs a local uncommitted edit — and nothing
in `.gitignore` protects such an edit from being committed by accident.
- **No WiFi credentials in the source any more** (#295, epic #99). Saved networks live in
NVS on the chip (`src/wifi_store.cpp`, namespace `wifi`, five entries, plain text by
decision). A fresh board joins nothing until it is provisioned: put `/config/wifi.txt`
on the SD card in the format in `wifi_store.h` (the owner types it, never an agent),
boot once, and the entries are imported; the file stays until you choose Remove on
the device or the `/wifi` page. Until then there is no `fieldcompass.local`. The
placeholders that sat here from the #245 rebuild until #295 are gone, so a `main`
build no longer needs a local uncommitted edit to get on the network.

Pre-migration commit SHAs quoted in old issues refer to the archive, not here.

Expand Down
51 changes: 51 additions & 0 deletions docs/plans/2026-09-09-wifi-configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# WiFi configuration — plan for epic #99

Approved by the owner on 2026-09-09 ("Proceed with the plan"). Children: #295, #296, #297, #298.

## Diagnosis (main e36a79f)

- Six credential literals in `src/web.cpp`, placeholders since the #245 rebuild. A build from `main` cannot join WiFi, so no `fieldcompass.local`, and every flash needs a local uncommitted edit that nothing protects from being committed. #147 is the security side of the same thing.
- `initWiFi()` blocks up to 22.5 s at boot (three networks, 7.5 s each). `checkWiFi()` blocks 5 s every 30 s while down, which is always while the credentials are placeholders. #292 measured that stall and lists "the WiFi state machine" as its first item.
- The issue was written in the TFT_eSPI era and plans a hand-built keyboard. The UI is LVGL 9.5 now, and `lv_keyboard`, `lv_textarea`, `lv_list` and `lv_spinner` are all enabled in `include/lv_conf.h`. The keyboard is a widget, not a project.
- The ESP32-S3 has no 5 GHz radio: Espressif specifies the part as "2.4 GHz Wi-Fi (802.11 b/g/n) with 40 MHz of bandwidth support" (espressif.com/en/products/socs/esp32-s3, read 2026-09-09). The second saved network (5 GHz) could never have connected, and #34's "prefer 5 GHz" option is impossible on this chip.

## Structure

#99 is a `type:epic`: several PRs, hardware verification at the end. Children, one branch and PR each per the FC per-issue override, in dependency order:

**1. #295 — Credential store and non-blocking WiFi** (closes #147)
- Store: ESP32 NVS through `Preferences`, namespace `wifi`, up to five entries in priority order, deduplicated by SSID, oldest replaced when full. On-chip flash, not in git, not on a removable card.
- Boot and reconnect become one non-blocking state machine: start `WiFi.begin()` for the first saved network, watch `WiFi.status()` and a timeout from `loop()`, move to the next, no `delay()` loops. This is the #292 item, delivered here because this child rewrites both functions anyway.
- One-shot SD import: if `/config/wifi.txt` is on the card at boot (the #99 format), its entries are imported into NVS, deduplicated by SSID. That is the field pre-loading path and the way real credentials reach the bench without touching git.
- **The file is never touched without asking (owner, 2026-09-09).** After an import the TFT shows a one-time pop-up: "Imported N networks from SD. Remove /config/wifi.txt?" with **Remove** in red and **Keep** (child 3). The `/wifi` page shows the same choice while the file is present (child 2). Until answered the file stays; a repeat import on the next boot is harmless because of the dedupe.
- The six literals go. Acceptance: no SSID or password literal in any tracked file, and a `main` build joins the home network after one import.

**2. #296 — `/wifi` web endpoint**
- List saved networks (SSID, priority, never the password), add, delete, move up. POST forms in the style of `/geocaches`. Same trust model as every other endpoint: plain HTTP on the LAN.
- Forget all networks with the same two-step confirm as the screen. The Remove/Keep choice for the SD file while it is present.

**3. #297 — Settings → WiFi sub-screen** (closes #34)
- Status block: SSID, IP, RSSI. A Scan button starts an async scan and fills an `lv_list`: SSID, RSSI bars, lock glyph for secured networks.
- Tap a network → `lv_textarea` (masked, show/hide) with `lv_keyboard` (lower, upper, numbers, symbols come with the widget; keys are about 48 × 40 px at this size) → Connect → spinner → "Connected, <IP>" or "Failed" with retry. Success saves to the store, replacing the oldest when full.
- Saved-network list with delete, so a bad entry can be removed without the web page.
- **Clear all saved networks (owner, 2026-09-09):** a red "Forget all networks" button at the bottom of the sub-screen. Tapping it opens an `lv_msgbox`: "This removes every saved network from the device. Are you sure?" with a red **Forget all** and **Cancel**. Only the confirm wipes the store, and it disconnects if connected.
- The RSSI display is the usable half of #34; the rest of #34 is retired by the 5 GHz fact above.

**4. #298 — Epic integration test on hardware** (CLAUDE.md § Epic Integration Testing, a dedicated task)
- Fresh board with no credentials → SD import → web add and delete → screen scan, connect, reboot, reconnect → loop pass never waits on WiFi (measured in the status line), touch stays live during a connect. Owner signs off.

## Two questions answered

**Do we need to encrypt on NVS?** Not for this. NVS is plain text on the flash chip, so the exposure is: someone with the device in hand, a USB cable, and esptool can dump the NVS partition and read the passwords. Against that: the same passwords were public from 2026-02 until the #245 rebuild on 2026-09-07 and still sit in the private archive (risk accepted 2026-08-08; the public repository is clean, verified 2026-09-09: none of the three 2026-02 commits that carry the value is an ancestor of any public branch, and GitHub has no such object in the public repo), the SD alternative is worse (any card reader, no tools), and the fix, ESP32 flash encryption plus NVS encryption, burns eFuses, is irreversible in release mode, needs an `nvs_keys` partition in both partition tables, and changes every flash we do through the wrapper. If the threat model ever becomes "device stolen, home network at risk", that is the answer, and it is its own epic. For now: NVS unencrypted.

**Does the Feather support 5 GHz?** No, see Diagnosis.

## Out of scope (accepted by the owner, 2026-09-09)

- A captive-portal access point for first provisioning. SD import and the web page cover it.
- Encryption at rest, per the answer above.
- Anything 5 GHz.

## Size

Child 1 about 300 lines, 2 about 250, 3 about 400. Each gets the Gemini gate and a flash. 1 and 2 can be verified in one bench session.
2 changes: 1 addition & 1 deletion src/src.ino
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ void setup() {
initFRAM(); // SPI FRAM 256KB (shared bus with TFT/SD)
initRTC(); // Adalogger RTC - sets system time if RTC has valid time
initSerialLog(rtcAvailable); // Serial log to SD (#59) - needs SD + RTC; RTC state passed in (#263)
initWiFi(); // Will sync NTP if connected, then sync RTC
initWiFi(); // Loads saved networks (NVS, SD import) and starts the first attempt; checkWiFi() in loop() does the rest (#295)

// Flush any FRAM data from previous session to SD
if (framAvailable && sdAvailable) {
Expand Down
192 changes: 117 additions & 75 deletions src/web.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,11 @@
#include "display.h"
#include "touch.h"
#include "geo.h"
#include "wifi_store.h" // saved networks live in NVS, not here (#295, epic #99)
#include <esp_sntp.h> // sntp_get_sync_status(): did a time server actually answer

const char* WIFI_SSID_1 = "REDACTED_SSID_1";
const char* WIFI_PASS_1 = "REDACTED_WIFI_PASSWORD";
const char* WIFI_SSID_2 = "REDACTED_SSID_2";
const char* WIFI_PASS_2 = "REDACTED_WIFI_PASSWORD";
const char* WIFI_SSID_3 = "REDACTED_SSID_3";
const char* WIFI_PASS_3 = "REDACTED_HOTSPOT_PASSWORD";
const char* NTP_SERVER = "pool.ntp.org";
WebServer webServer(WEB_SERVER_PORT);
unsigned long lastWiFiAttempt = 0;
bool wifiConnected = false;
bool ntpSynced = false;
bool webServerStarted = false;
Expand All @@ -36,89 +31,136 @@ static String gpxUploadBuffer;
static bool gpxUploadSuccess = false;
static String gpxUploadError;

void initWiFi() {
logPrint("Connecting to WiFi");

// Try networks in order
const char* ssids[] = {WIFI_SSID_1, WIFI_SSID_2, WIFI_SSID_3};
const char* passwords[] = {WIFI_PASS_1, WIFI_PASS_2, WIFI_PASS_3};

for (int net = 0; net < 3; net++) {
logPrintf(" [%s]", ssids[net]);

WiFi.begin(ssids[net], passwords[net]);

int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 15) {
delay(500);
logPrint(".");
attempts++;
}

if (WiFi.status() == WL_CONNECTED) break;
// ---- WiFi: one non-blocking state machine (#295, epic #99) -----------------
// The old initWiFi() blocked setup() for up to 22.5 s and checkWiFi() blocked
// loop() for 5 s every 30 s while the network was down (#292 measured it).
// Nothing here waits: initWiFi() starts the first attempt and returns, and
// checkWiFi(), called every loop pass, moves the machine along by looking at
// WiFi.status() and the clock. Saved networks are tried in store order; when
// none answers the machine rests WIFI_RECONNECT_INTERVAL and starts over. A
// network added at runtime (#296, #297) is picked up from IDLE or on the next
// round. NTP is started on connect and checked on later passes, never waited on.
enum WifiState { WIFI_ST_IDLE, WIFI_ST_CONNECTING, WIFI_ST_CONNECTED, WIFI_ST_WAIT };
static WifiState wifiState = WIFI_ST_IDLE;
static int wifiTryIndex = 0;
static unsigned long wifiStateSince = 0;
static bool ntpPending = false;
static unsigned long ntpSince = 0;
#define WIFI_CONNECT_TIMEOUT_MS 8000 // per network, about what the old 15 x 500 ms gave
#define NTP_TIMEOUT_MS 15000

static void wifiStartAttempt(int idx) {
WifiCred c;
if (!wifiStoreGet(idx, c)) {
wifiState = WIFI_ST_WAIT;
wifiStateSince = millis();
return;
}
logPrintf("[WIFI] Connecting to %s (%d of %d)\n", c.ssid, idx + 1, wifiStoreCount());
WiFi.begin(c.ssid, c.pass);
wifiTryIndex = idx;
wifiState = WIFI_ST_CONNECTING;
wifiStateSince = millis();
}

if (WiFi.status() == WL_CONNECTED) {
wifiConnected = true;
logPrintln(" OK");
logPrintf(" IP: %s\n", WiFi.localIP().toString().c_str());
void initWiFi() {
WiFi.mode(WIFI_STA);
WiFi.persistent(false); // the core keeps no copy of the credentials in its own NVS area
WiFi.setAutoReconnect(false); // this machine is the only thing that reconnects, so the log is truthful
wifiStoreInit();
wifiStoreImportFromSD();
if (wifiStoreCount() == 0) {
logPrintf("[WIFI] No saved networks. Put %s on the SD card (see docs/plans/2026-09-09-wifi-configuration.md)\n",
WIFI_IMPORT_PATH);
wifiState = WIFI_ST_IDLE;
return;
}
wifiStartAttempt(0);
}

// Sync NTP time
logPrint("Syncing NTP time... ");
static void wifiOnConnected() {
wifiConnected = true;
wifiState = WIFI_ST_CONNECTED;
wifiStateSince = millis();
logPrintf("[WIFI] Connected to %s, IP %s, RSSI %d dBm\n",
WiFi.SSID().c_str(), WiFi.localIP().toString().c_str(), WiFi.RSSI());
if (!ntpSynced) {
configTime(0, 0, NTP_SERVER); // NTP provides UTC; POSIX TZ handles offset (#98)
applyTimezone(); // Ensure TZ is set after configTime
ntpPending = true;
ntpSince = millis();
}
if (!webServerStarted) initWebServer();
}

struct tm timeinfo;
if (getLocalTime(&timeinfo, 5000)) {
ntpSynced = true;
logPrintln("OK");

// Sync RTC from NTP (if GPS hasn't already synced it)
if (!rtcSyncedFromGPS && !rtcSyncedFromNTP) {
syncRTCFromSystemTime("NTP");
rtcSyncedFromNTP = true;
}
} else {
logPrintln("FAILED");
static void wifiServiceNtp() {
if (!ntpPending) return;
// The clock being sane proves nothing here: the RTC sets system time at
// boot and GPS may have too (review finding on #295). The SNTP client's own
// status says whether a server answered; it reads COMPLETED once, then
// resets, so it is polled every pass and acted on the pass it appears.
if (sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED) {
ntpPending = false;
ntpSynced = true;
logPrintln("[NTP] Synced");
// Sync RTC from NTP (if GPS hasn't already synced it)
if (!rtcSyncedFromGPS && !rtcSyncedFromNTP) {
syncRTCFromSystemTime("NTP");
rtcSyncedFromNTP = true;
}
} else {
logPrintln(" FAILED");
} else if (millis() - ntpSince > NTP_TIMEOUT_MS) {
ntpPending = false;
logPrintln("[NTP] No answer in 15 s; GPS/RTC time stands");
}

lastWiFiAttempt = millis();
}

void checkWiFi() {
// Update connection status
wifiConnected = (WiFi.status() == WL_CONNECTED);

// Attempt reconnect if disconnected
if (!wifiConnected && (millis() - lastWiFiAttempt > WIFI_RECONNECT_INTERVAL)) {
logPrintln("WiFi disconnected, attempting reconnect...");
WiFi.reconnect();
lastWiFiAttempt = millis();

// Wait briefly for connection
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 10) {
delay(500);
attempts++;
}
wl_status_t st = WiFi.status();
unsigned long now = millis();
switch (wifiState) {
case WIFI_ST_IDLE:
if (wifiStoreCount() > 0) wifiStartAttempt(0); // a network was added since boot
break;

case WIFI_ST_CONNECTING:
if (st == WL_CONNECTED) {
wifiOnConnected();
break;
}
if (st == WL_CONNECT_FAILED || st == WL_NO_SSID_AVAIL ||
now - wifiStateSince > WIFI_CONNECT_TIMEOUT_MS) {
WifiCred c;
wifiStoreGet(wifiTryIndex, c);
logPrintf("[WIFI] %s: %s\n", c.ssid,
st == WL_NO_SSID_AVAIL ? "not in range" :
st == WL_CONNECT_FAILED ? "rejected (password?)" : "no answer in 8 s");
int next = wifiTryIndex + 1;
if (next < wifiStoreCount()) {
wifiStartAttempt(next);
} else {
WiFi.disconnect();
wifiState = WIFI_ST_WAIT;
wifiStateSince = now;
logPrintf("[WIFI] No saved network reachable; trying again in %d s\n", WIFI_RECONNECT_INTERVAL / 1000);
}
}
break;

wifiConnected = (WiFi.status() == WL_CONNECTED);
if (wifiConnected) {
logPrintln("WiFi reconnected!");
// Start web server if not already running
if (!webServerStarted) {
initWebServer();
case WIFI_ST_CONNECTED:
if (st != WL_CONNECTED) {
wifiConnected = false;
logPrintln("[WIFI] Connection lost; reconnecting");
wifiStartAttempt(0);
}
}
}
break;

// Ensure web server is started if WiFi is connected
if (wifiConnected && !webServerStarted) {
initWebServer();
case WIFI_ST_WAIT:
if (now - wifiStateSince > WIFI_RECONNECT_INTERVAL) wifiStartAttempt(0);
break;
}

wifiServiceNtp();
if (wifiConnected && !webServerStarted) initWebServer();
}

void handleWebRoot() {
Expand Down
Loading