Selaa lähdekoodia

NVS credentials + AP fallback

Benjamin Harris 1 kuukausi sitten
vanhempi
sitoutus
0adaa83c9c
5 muutettua tiedostoa jossa 228 lisäystä ja 21 poistoa
  1. 23 9
      CLAUDE.md
  2. 117 6
      ModulosDSP_101.ino
  3. 21 6
      README.md
  4. 38 0
      ap_html.h
  5. 29 0
      ota_html.h

+ 23 - 9
CLAUDE.md

@@ -31,16 +31,29 @@ The build identifier uses `YYMMDD_rev` format (e.g. `260304_13`). It appears in
 
 When cutting a new build, update both. The `version` constant is displayed on the LCD and printed to Serial at boot.
 
-## WiFi Credentials
+## WiFi Credentials — NVS + AP Fallback
 
-Hardcoded at the top of `ModulosDSP_101.ino`:
+Credentials are stored in NVS using the `Preferences` library (namespace `"wifi"`, keys `"ssid"` and `"pass"`). There are no hardcoded credentials in the source.
 
-```cpp
-const char* ssid     = "alfred";
-const char* password = "alfred16";
-```
+**Boot sequence:**
+
+1. `loadWifiCreds()` reads from NVS. If the `ssid` key is empty (first boot or after a reset), `startConfigAP()` is called — it never returns.
+2. Otherwise, `WiFi.begin(s_ssid, s_pass)` is called with a 15-second connection timeout.
+3. If the connection fails (wrong password, network not in range), `startConfigAP()` is called.
+
+**Config AP (`startConfigAP()`):**
+
+- Starts `WIFI_AP` mode with SSID `"ModulosDSP-Setup"` (no password).
+- Default IP: `192.168.4.1`.
+- `httpServer` serves `AP_HTML` (from `ap_html.h`) at `GET /`, and handles `POST /save` with `ssid` + `pass` form fields.
+- On save: `saveWifiCreds()` writes to NVS, then `ESP.restart()`. Never returns.
+- LCD shows: row 0 `"WiFi Setup Mode"`, row 1 `"ModulosDSP-Setup"`, row 2 IP address.
+
+**Resetting credentials:**
+
+`POST /wifi_reset` calls `clearWifiCreds()` then `ESP.restart()`. The device reboots into AP mode. The `/ota` page has a **Reset WiFi Credentials** button that calls this endpoint (with a `confirm()` dialog before sending).
 
-Change before flashing to a different network. No runtime config exists. `WiFi.setAutoReconnect(true)` is set in `setup()` and `maintainWifi()` handles mid-session drops.
+`WiFi.setAutoReconnect(true)` is set before `WiFi.begin()`. `maintainWifi()` calls `WiFi.reconnect()` on drop, which reuses the last-configured credentials from the WiFi stack — no need to re-pass `s_ssid`/`s_pass`.
 
 ## WiFi Watchdog — `maintainWifi()`
 
@@ -185,7 +198,7 @@ ADAU1401 parameter RAM uses **5.23 fixed-point** (4 bytes per word). `DataConver
 ## Known Stubs and Intentional Gaps
 
 - `DSPWriter::downloadProgram()` is commented out. It was intended for standalone firmware loading at boot; this use case is now handled via the EEPROM (DSP reads EEPROM autonomously via SELFBOOT pin).
-- WiFi credentials are compile-time constants — no captive portal or NVS storage.
+- WiFi credentials are stored in NVS; the config portal (`startConfigAP()`) handles first-boot and failed-connect scenarios.
 - Only one TCP client is served at a time; a second SigmaStudio instance cannot connect simultaneously.
 
 ## Files
@@ -197,4 +210,5 @@ ADAU1401 parameter RAM uses **5.23 fixed-point** (4 bytes per word). `DataConver
 | `DSPWriter.cpp` | `writeRegister`, `writeRegisterBlock` (returns bool), safeload with flush |
 | `DataConversion.h` / `.cpp` | 5.23 fixed-point to C type conversions |
 | `index_html.h` | Embedded HTML string for the EEPROM upload UI |
-| `ota_html.h` | Embedded HTML string for the OTA firmware update UI |
+| `ota_html.h` | Embedded HTML string for the OTA / device management UI |
+| `ap_html.h` | Embedded HTML string for the SoftAP WiFi config portal |

+ 117 - 6
ModulosDSP_101.ino

@@ -53,6 +53,7 @@
 #include <WiFi.h>
 #include <Wire.h>
 #include <WebServer.h>
+#include <Preferences.h>
 #include "DSPWriter.h"
 #include <hd44780.h>
 #include <hd44780ioClass/hd44780_I2Cexp.h>
@@ -65,11 +66,13 @@
 //=============================================================
 // WiFi / UI
 //=============================================================
-const char* ssid     = "alfred";
-const char* password = "alfred16";
 const char* hostname = "modulos-dsp";
 const char* version  = "VER: 260304_13";
 
+// Runtime WiFi credentials — loaded from NVS at boot
+static char s_ssid[64] = "";
+static char s_pass[64] = "";
+
 #define I2C_SDA 13
 #define I2C_SCL 12
 
@@ -78,6 +81,7 @@ WebServer  httpServer(80);
 
 #include "index_html.h"
 #include "ota_html.h"
+#include "ap_html.h"
 
 hd44780_I2Cexp lcd;
 static bool s_lcdOk = false;
@@ -318,6 +322,88 @@ static void printHex(const char* label, const uint8_t* buf, uint16_t len, uint16
   Serial.println();
 }
 
+//=============================================================
+// NVS credential helpers
+//=============================================================
+static Preferences s_prefs;
+
+static bool loadWifiCreds()
+{
+  s_prefs.begin("wifi", true);
+  String ssid = s_prefs.getString("ssid", "");
+  String pass = s_prefs.getString("pass", "");
+  s_prefs.end();
+  if (ssid.length() == 0) return false;
+  ssid.toCharArray(s_ssid, sizeof(s_ssid));
+  pass.toCharArray(s_pass, sizeof(s_pass));
+  return true;
+}
+
+static void saveWifiCreds(const String& ssid, const String& pass)
+{
+  s_prefs.begin("wifi", false);
+  s_prefs.putString("ssid", ssid);
+  s_prefs.putString("pass", pass);
+  s_prefs.end();
+}
+
+static void clearWifiCreds()
+{
+  s_prefs.begin("wifi", false);
+  s_prefs.clear();
+  s_prefs.end();
+}
+
+//=============================================================
+// SoftAP config portal — runs when no credentials are stored
+// or when a previous connection attempt failed.
+// Serves a simple HTML form; never returns.
+//=============================================================
+static void startConfigAP()
+{
+  Serial.println("Starting config AP: ModulosDSP-Setup");
+  WiFi.mode(WIFI_AP);
+  WiFi.softAP("ModulosDSP-Setup");
+  delay(100);
+  IPAddress apIP = WiFi.softAPIP();
+  Serial.print("AP IP: "); Serial.println(apIP);
+
+  if (s_lcdOk) {
+    lcd.clear();
+    lcd.setCursor(0, 0); lcd.print("WiFi Setup Mode");
+    lcd.setCursor(0, 1); lcd.print("ModulosDSP-Setup");
+    lcd.setCursor(0, 2); lcd.print(apIP);
+  }
+
+  httpServer.on("/", HTTP_GET, []() {
+    httpServer.send_P(200, "text/html", AP_HTML);
+  });
+
+  httpServer.on("/save", HTTP_POST, []() {
+    if (!httpServer.hasArg("ssid") || httpServer.arg("ssid").length() == 0) {
+      httpServer.send(400, "text/plain", "SSID is required.");
+      return;
+    }
+    String newSsid = httpServer.arg("ssid");
+    String newPass = httpServer.arg("pass");
+    saveWifiCreds(newSsid, newPass);
+    Serial.printf("Credentials saved for SSID: %s\n", newSsid.c_str());
+    httpServer.send(200, "text/html",
+      "<html><body style='font-family:sans-serif;max-width:380px;margin:60px auto;padding:0 20px'>"
+      "<h3>Saved!</h3><p>Connecting to <strong>" + newSsid +
+      "</strong>&hellip; The device will reboot now.</p></body></html>");
+    delay(1000);
+    ESP.restart();
+  });
+
+  httpServer.begin();
+  Serial.println("Config portal active — waiting for credentials");
+  while (true) {
+    httpServer.handleClient();
+    delay(1);
+  }
+}
+
 static void printWifiInfo()
 {
   Serial.println();
@@ -497,6 +583,19 @@ static void handleDspStatus() {
   httpServer.send(allOk ? 200 : 500, "application/json", buf);
 }
 
+//=============================================================
+// HTTP WiFi reset handler  POST /wifi_reset
+//=============================================================
+static void handleWifiReset() {
+  Serial.println("WiFi credentials cleared — rebooting to AP setup mode");
+  clearWifiCreds();
+  httpServer.send(200, "text/plain",
+    "Credentials cleared. Rebooting into setup mode.\n"
+    "Connect to 'ModulosDSP-Setup' and open 192.168.4.1.");
+  delay(500);
+  ESP.restart();
+}
+
 //=============================================================
 // HTTP GPIO handlers  GET /gpio  POST /gpio
 //=============================================================
@@ -610,12 +709,23 @@ void setup() {
   Serial.println(); Serial.println("Booting...");
   Serial.printf("Reset reason: %d\n", (int)esp_reset_reason());
 
+  if (!loadWifiCreds()) {
+    Serial.println("No WiFi credentials stored — entering setup mode");
+    startConfigAP(); // never returns
+  }
+
+  Serial.printf("Connecting to %s", s_ssid);
   WiFi.mode(WIFI_STA);
   WiFi.setAutoReconnect(true);
-  WiFi.begin(ssid, password);
-  while (WiFi.waitForConnectResult() != WL_CONNECTED) {
-    Serial.println("Connection Failed! Rebooting...");
-    delay(5000); ESP.restart();
+  WiFi.begin(s_ssid, s_pass);
+  uint32_t t0 = millis();
+  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) {
+    delay(500); Serial.print(".");
+  }
+  Serial.println();
+  if (WiFi.status() != WL_CONNECTED) {
+    Serial.println("WiFi connect failed — entering setup mode");
+    startConfigAP(); // never returns
   }
 
   if (MDNS.begin(hostname)) {
@@ -645,6 +755,7 @@ void setup() {
   httpServer.on("/dsp_status", HTTP_GET,  handleDspStatus);
   httpServer.on("/gpio",       HTTP_GET,  handleGpioGet);
   httpServer.on("/gpio",       HTTP_POST, handleGpioSet);
+  httpServer.on("/wifi_reset", HTTP_POST, handleWifiReset);
   httpServer.onNotFound([]() {
     String uri = httpServer.uri();
     if (streamFromFS(uri)) return;

+ 21 - 6
README.md

@@ -51,15 +51,29 @@ Install via **Sketch → Include Library → Manage Libraries**:
 
 ## Configuration
 
-Before flashing, edit `ModulosDSP_101.ino` and update the WiFi credentials and hostname:
+### WiFi Credentials
+
+Credentials are stored in NVS (non-volatile flash) using the `Preferences` library — there are no hardcoded SSID or password in the source code.
+
+**First boot / no credentials stored:**
+
+1. The device starts a SoftAP named **`ModulosDSP-Setup`** (open, no password).
+2. Connect your phone or laptop to `ModulosDSP-Setup`.
+3. Open `http://192.168.4.1` in a browser — a setup page appears.
+4. Enter your WiFi SSID and password and click **Save & Connect**.
+5. The device saves the credentials to NVS and reboots, joining your network.
+
+**If the connection fails** (wrong password, network out of range) the device falls back to AP mode automatically.
+
+**Resetting credentials:** on the Device Management page (`/ota`) click **Reset WiFi Credentials**. The device clears NVS and reboots into AP mode.
+
+### Hostname
 
 ```cpp
-const char* ssid     = "your_network_ssid";
-const char* password = "your_network_password";
-const char* hostname = "modulos-dsp";        // resolves as modulos-dsp.local
+const char* hostname = "modulos-dsp";   // resolves as modulos-dsp.local
 ```
 
-There is no runtime configuration — credentials are compiled in. The hostname is registered via mDNS (Bonjour/Zeroconf), so once on the network the device is reachable at `modulos-dsp.local` without knowing its IP address. Change the hostname constant if you run more than one unit on the same network.
+The hostname is the only compile-time network constant. It is registered via mDNS (Bonjour/Zeroconf), so once on the network the device is reachable at `modulos-dsp.local`. Change it if you run more than one unit on the same network.
 
 ---
 
@@ -182,7 +196,8 @@ ModulosDSP_101/
 ├── DataConversion.h     5.23 fixed-point conversion declarations
 ├── DataConversion.cpp   Fixed-point conversion implementations
 ├── index_html.h         Embedded HTML for the EEPROM upload web UI
-└── ota_html.h           Embedded HTML for the OTA firmware update web UI
+├── ota_html.h           Embedded HTML for the OTA / device management web UI
+└── ap_html.h            Embedded HTML for the SoftAP WiFi config portal
 ```
 
 ---

+ 38 - 0
ap_html.h

@@ -0,0 +1,38 @@
+#pragma once
+#include <pgmspace.h>
+
+static const char AP_HTML[] PROGMEM = R"HTML(
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Modulos DSP &mdash; WiFi Setup</title>
+  <style>
+    body   { font-family: sans-serif; max-width: 380px; margin: 60px auto; padding: 0 20px; }
+    h3     { margin-bottom: 0.3rem; }
+    p      { color: #666; font-size: 0.88rem; margin-top: 0.2rem; }
+    label  { display: block; margin-top: 1rem; font-size: 0.88rem; font-weight: 600; }
+    input  { width: 100%; padding: 8px; margin-top: 4px; box-sizing: border-box;
+             border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; }
+    button { display: block; width: 100%; margin-top: 1.4rem; padding: 10px;
+             background: #f0ad4e; color: #000; border: none; border-radius: 4px;
+             font-size: 1rem; cursor: pointer; }
+    button:hover { background: #ec971f; }
+  </style>
+</head>
+<body>
+  <h3>Modulos DSP &mdash; WiFi Setup</h3>
+  <p>Enter credentials for your WiFi network. The device will reboot and join automatically.</p>
+  <form method="POST" action="/save">
+    <label>Network SSID
+      <input name="ssid" autocomplete="off" required>
+    </label>
+    <label>Password
+      <input type="password" name="pass" autocomplete="new-password">
+    </label>
+    <button type="submit">Save &amp; Connect</button>
+  </form>
+</body>
+</html>
+)HTML";

+ 29 - 0
ota_html.h

@@ -98,6 +98,17 @@ static const char OTA_HTML[] PROGMEM = R"HTML(
   </div>
   <div id="gpioResult" style="display:none; margin-top:0.4rem;" class="alert alert-sm" role="alert"></div>
 
+  <hr class="mt-3">
+  <h6 class="text-muted mb-2"><i class="fa fa-wifi"></i> WiFi Settings</h6>
+  <button class="btn btn-outline-danger w-100" onclick="wifiReset()">
+    <i class="fa fa-trash-o"></i>&nbsp; Reset WiFi Credentials
+  </button>
+  <div class="form-text mb-1">
+    Clears stored credentials and reboots into setup mode.
+    Connect to <strong>ModulosDSP-Setup</strong> then open <strong>192.168.4.1</strong> to reconfigure.
+  </div>
+  <div id="wifiResetResult" style="display:none; margin-top:0.4rem;" class="alert alert-sm" role="alert"></div>
+
   <hr class="mt-3">
   <a href="/" class="text-muted small"><i class="fa fa-arrow-left"></i> Back to EEPROM Uploader</a>
 </div>
@@ -225,6 +236,24 @@ function toggleGpioPin(pin) {
 fetchGpio();
 setInterval(fetchGpio, 5000);
 
+function wifiReset() {
+  if (!confirm('Clear WiFi credentials and reboot into setup mode?')) return;
+  var box = document.getElementById('wifiResetResult');
+  var xhr = new XMLHttpRequest();
+  xhr.open('POST', '/wifi_reset', true);
+  xhr.onload = function() {
+    box.style.display = 'block';
+    box.className = 'alert alert-warning alert-sm';
+    box.innerHTML = '<i class="fa fa-info-circle"></i> ' + xhr.responseText;
+  };
+  xhr.onerror = function() {
+    box.style.display = 'block';
+    box.className = 'alert alert-danger alert-sm';
+    box.innerHTML = '<i class="fa fa-times"></i> Request failed.';
+  };
+  xhr.send();
+}
+
 document.getElementById('otaForm').addEventListener('submit', function(e) {
   e.preventDefault();
   var file = document.getElementById('fwFile').files[0];