677 lines
27 KiB
C
Executable File
677 lines
27 KiB
C
Executable File
// Copyright (C) 2026, Boundary Mode Extension
|
|
// Based on microReticulum_Firmware by Mark Qvist
|
|
//
|
|
// BoundaryConfig.h — Captive-portal web configuration for the legacy
|
|
// "Boundary Mode" path. This should be renamed to "Transport Mode"
|
|
// together with the rest of the boundary-mode terminology. In this fork,
|
|
// transport/boundary mode is the only intended mode of operation.
|
|
// When triggered (first boot with no config, or button hold >5s),
|
|
// the device starts a WiFi AP with a web form for all settings:
|
|
// WiFi STA credentials, TCP backbone params, LoRa radio params,
|
|
// and optional AP-mode TCP server.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
|
|
#ifndef BOUNDARY_CONFIG_H
|
|
#define BOUNDARY_CONFIG_H
|
|
|
|
#ifdef BOUNDARY_MODE
|
|
|
|
#include <WiFi.h>
|
|
#include <WebServer.h>
|
|
#include <DNSServer.h>
|
|
|
|
// ─── Node hash (cached in RTC by normal boot, read here without starting RNS) ─
|
|
#define NODE_HASH_RTC_MAGIC 0x504B4841UL
|
|
extern uint32_t rtc_node_hash_magic;
|
|
extern char rtc_node_hash_hex[33];
|
|
|
|
// ─── Config Portal State ─────────────────────────────────────────────────────
|
|
static bool config_portal_active = false;
|
|
static WebServer* config_server = nullptr;
|
|
static DNSServer* config_dns = nullptr;
|
|
|
|
static const char CONFIG_AP_SSID[] = "RNode-Boundary-Setup";
|
|
static const uint16_t DNS_PORT = 53;
|
|
static const uint16_t HTTP_PORT = 80;
|
|
|
|
// Forward declarations
|
|
void config_portal_start();
|
|
void config_portal_stop();
|
|
void config_portal_loop();
|
|
bool config_portal_is_active();
|
|
bool boundary_needs_config();
|
|
|
|
// ─── Common bandwidth values (Hz) ───────────────────────────────────────────
|
|
// These match Reticulum standard channel plans
|
|
// Stored in flash (PROGMEM) to save ~200 bytes of RAM
|
|
static const uint32_t BW_OPTIONS_HZ[] PROGMEM = {
|
|
7800, 10400, 15600, 20800, 31250, 41700, 62500, 125000, 250000, 500000,
|
|
};
|
|
static const char BW_LABEL_0[] PROGMEM = "7.8 kHz";
|
|
static const char BW_LABEL_1[] PROGMEM = "10.4 kHz";
|
|
static const char BW_LABEL_2[] PROGMEM = "15.6 kHz";
|
|
static const char BW_LABEL_3[] PROGMEM = "20.8 kHz";
|
|
static const char BW_LABEL_4[] PROGMEM = "31.25 kHz";
|
|
static const char BW_LABEL_5[] PROGMEM = "41.7 kHz";
|
|
static const char BW_LABEL_6[] PROGMEM = "62.5 kHz";
|
|
static const char BW_LABEL_7[] PROGMEM = "125 kHz";
|
|
static const char BW_LABEL_8[] PROGMEM = "250 kHz";
|
|
static const char BW_LABEL_9[] PROGMEM = "500 kHz";
|
|
static const char* const BW_OPTIONS_LABELS[] PROGMEM = {
|
|
BW_LABEL_0, BW_LABEL_1, BW_LABEL_2, BW_LABEL_3, BW_LABEL_4,
|
|
BW_LABEL_5, BW_LABEL_6, BW_LABEL_7, BW_LABEL_8, BW_LABEL_9,
|
|
};
|
|
static const int BW_OPTIONS_COUNT = sizeof(BW_OPTIONS_HZ) / sizeof(BW_OPTIONS_HZ[0]);
|
|
|
|
static uint8_t config_default_display_rotation() {
|
|
#if BOARD_MODEL == BOARD_LORA32_V2_1 || BOARD_MODEL == BOARD_TBEAM || BOARD_MODEL == BOARD_RAK4631
|
|
return 0;
|
|
#elif BOARD_MODEL == BOARD_HELTEC32_V2 || BOARD_MODEL == BOARD_HELTEC32_V3 || BOARD_MODEL == BOARD_HELTEC32_V4 || BOARD_MODEL == BOARD_HELTEC_T114 || BOARD_MODEL == BOARD_TBEAM_S_V1
|
|
return 1;
|
|
#else
|
|
return 3;
|
|
#endif
|
|
}
|
|
|
|
// ─── HTML Page Generation ────────────────────────────────────────────────────
|
|
|
|
static void config_send_html() {
|
|
// Read current values from EEPROM/globals for pre-population
|
|
char cur_ssid[33] = "";
|
|
char cur_psk[33] = "";
|
|
|
|
for (int i = 0; i < 32; i++) {
|
|
cur_ssid[i] = EEPROM.read(config_addr(ADDR_CONF_SSID + i));
|
|
if (cur_ssid[i] == (char)0xFF) cur_ssid[i] = '\0';
|
|
}
|
|
cur_ssid[32] = '\0';
|
|
|
|
for (int i = 0; i < 32; i++) {
|
|
cur_psk[i] = EEPROM.read(config_addr(ADDR_CONF_PSK + i));
|
|
if (cur_psk[i] == (char)0xFF) cur_psk[i] = '\0';
|
|
}
|
|
cur_psk[32] = '\0';
|
|
|
|
// Current LoRa values (from globals, which were loaded from EEPROM)
|
|
uint32_t cur_freq = lora_freq;
|
|
uint32_t cur_bw = lora_bw;
|
|
int cur_sf = lora_sf;
|
|
int cur_cr = lora_cr;
|
|
int cur_txp = lora_txp;
|
|
if (cur_txp == 0xFF) cur_txp = PA_MAX_OUTPUT; // Default to board max
|
|
|
|
// Default frequency if not set
|
|
if (cur_freq == 0) cur_freq = 868825000; // 914.875 MHz default
|
|
if (cur_bw == 0) cur_bw = 125000; // 125 kHz default
|
|
if (cur_sf == 0) cur_sf = 10; // SF10 default
|
|
if (cur_cr < 5 || cur_cr > 8) cur_cr = 7; // CR 4/5 default
|
|
|
|
// Build the HTML page
|
|
String html = F(
|
|
"<!DOCTYPE html><html><head>"
|
|
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
|
"<title>RNode Boundary Setup</title>"
|
|
"<style>"
|
|
"body{font-family:sans-serif;background:#1a1a2e;color:#e0e0e0;margin:0;padding:16px;}"
|
|
"h1{color:#e94560;font-size:1.4em;margin:0 0 8px;}"
|
|
"h2{color:#0f3460;background:#e0e0e0;padding:6px 10px;margin:18px -10px 10px;font-size:1em;border-radius:4px;}"
|
|
"form{max-width:480px;margin:0 auto;}"
|
|
"label{display:block;margin:8px 0 2px;font-size:0.9em;color:#aaa;}"
|
|
"input,select{width:100%;padding:8px;margin:2px 0 6px;box-sizing:border-box;"
|
|
"background:#16213e;border:1px solid #0f3460;color:#e0e0e0;border-radius:4px;font-size:0.95em;}"
|
|
"input:focus,select:focus{border-color:#e94560;outline:none;}"
|
|
".row{display:flex;gap:10px;}.row>div{flex:1;}"
|
|
".note{font-size:0.8em;color:#666;margin:2px 0 8px;}"
|
|
"button{width:100%;padding:12px;margin:20px 0;background:#e94560;color:#fff;"
|
|
"border:none;border-radius:4px;font-size:1.1em;cursor:pointer;}"
|
|
"button:hover{background:#c73e54;}"
|
|
".ok{background:#16213e;padding:20px;border-radius:8px;text-align:center;}"
|
|
".ok h1{color:#0f0;}"
|
|
".node-hash{background:#0f1a30;border:1px solid #0f3460;border-radius:6px;"
|
|
"padding:10px 14px;margin:0 0 16px;}"
|
|
".node-hash .nh-label{display:block;font-size:0.75em;color:#888;margin-bottom:4px;}"
|
|
".node-hash code{font-family:monospace;font-size:0.95em;color:#7ecfff;"
|
|
"word-break:break-all;letter-spacing:0.05em;}"
|
|
"</style></head><body>"
|
|
"<h1>📡 RNode Boundary Node</h1>"
|
|
);
|
|
|
|
// ── Node public hash ──
|
|
html += F("<div class='node-hash'><span class='nh-label'>🔑 Node Hash (Reticulum destination)</span><code>");
|
|
if (rtc_node_hash_magic == NODE_HASH_RTC_MAGIC && rtc_node_hash_hex[0] != '\0') {
|
|
html += String(rtc_node_hash_hex);
|
|
} else {
|
|
html += F("<span style='color:#888;font-style:italic;'>Not yet assigned — will be set on first normal boot</span>");
|
|
}
|
|
html += F("</code></div>");
|
|
|
|
html += F("<form method='POST' action='/save'>");
|
|
html += F(
|
|
"<h2>📶 WiFi Network</h2>"
|
|
"<label>WiFi</label>"
|
|
"<select name='wifi_en'>"
|
|
);
|
|
html += F("<option value='1'");
|
|
if (boundary_state.wifi_enabled) html += F(" selected");
|
|
html += F(">Enabled</option>");
|
|
html += F("<option value='0'");
|
|
if (!boundary_state.wifi_enabled) html += F(" selected");
|
|
html += F(">Disabled (LoRa-only repeater)</option>");
|
|
html += F("</select>");
|
|
|
|
html += F(
|
|
"<label>SSID</label>"
|
|
"<input name='ssid' maxlength='32' placeholder='Your WiFi network' value='"
|
|
);
|
|
html += String(cur_ssid);
|
|
html += F(
|
|
"'>"
|
|
"<label>Password</label>"
|
|
"<input name='psk' type='password' maxlength='32' placeholder='WiFi password' value='"
|
|
);
|
|
html += String(cur_psk);
|
|
html += F("'>");
|
|
|
|
// ── TCP Backbone Section ──
|
|
html += F(
|
|
"<h2>🌐 TCP Backbone</h2>"
|
|
"<label>Mode</label>"
|
|
"<select name='tcp_mode'>"
|
|
);
|
|
html += F("<option value='0'");
|
|
if (boundary_state.tcp_mode == 0) html += F(" selected");
|
|
html += F(">Disabled</option>");
|
|
html += F("<option value='1'");
|
|
if (boundary_state.tcp_mode == 1) html += F(" selected");
|
|
html += F(">Client (connect to backbone)</option>");
|
|
html += F("</select>");
|
|
|
|
html += F("<label>Backbone Host</label>");
|
|
html += F("<input name='bb_host' maxlength='63' placeholder='e.g. 192.168.1.100' value='");
|
|
html += String(boundary_state.backbone_host);
|
|
html += F("'>");
|
|
|
|
html += F("<label>Backbone Port</label>");
|
|
html += F("<input name='bb_port' type='number' min='1' max='65535' value='");
|
|
html += String(boundary_state.backbone_port);
|
|
html += F("'>");
|
|
|
|
// ── Local TCP Server Section ──
|
|
html += F(
|
|
"<h2>📡 Local TCP Server (optional)</h2>"
|
|
"<p class='note'>Run a TCP server on the same WiFi network so local devices can connect. "
|
|
"Uses Access Point mode (does not forward announces).</p>"
|
|
"<label>Local TCP Server</label>"
|
|
"<select name='ap_tcp_en'>"
|
|
);
|
|
html += F("<option value='0'");
|
|
if (!boundary_state.ap_tcp_enabled) html += F(" selected");
|
|
html += F(">Disabled</option>");
|
|
html += F("<option value='1'");
|
|
if (boundary_state.ap_tcp_enabled) html += F(" selected");
|
|
html += F(">Enabled</option>");
|
|
html += F("</select>");
|
|
|
|
html += F("<label>TCP Port</label>");
|
|
html += F("<input name='ap_tcp_port' type='number' min='1' max='65535' value='");
|
|
html += String(boundary_state.ap_tcp_port);
|
|
html += F("'>");
|
|
|
|
// ── LoRa Radio Section ──
|
|
html += F(
|
|
"<h2>📻 LoRa Radio</h2>"
|
|
);
|
|
|
|
// Frequency — show in MHz for human-friendliness
|
|
char freq_str[16];
|
|
dtostrf((double)cur_freq / 1000000.0, 1, 3, freq_str);
|
|
html += F("<label>Frequency (MHz)</label>");
|
|
html += F("<input name='freq' type='text' placeholder='914.875' value='");
|
|
html += String(freq_str);
|
|
html += F("'>");
|
|
html += F("<p class='note'>e.g. 914.875, 868.000, 433.000</p>");
|
|
|
|
// Bandwidth — dropdown
|
|
html += F("<label>Bandwidth</label><select name='bw'>");
|
|
for (int i = 0; i < BW_OPTIONS_COUNT; i++) {
|
|
uint32_t bw_hz = pgm_read_dword(&BW_OPTIONS_HZ[i]);
|
|
char label_buf[16];
|
|
strncpy_P(label_buf, (const char*)pgm_read_ptr(&BW_OPTIONS_LABELS[i]), sizeof(label_buf)-1);
|
|
label_buf[sizeof(label_buf)-1] = '\0';
|
|
html += F("<option value='");
|
|
html += String(bw_hz);
|
|
html += "'";
|
|
if (bw_hz == cur_bw) html += F(" selected");
|
|
html += ">";
|
|
html += label_buf;
|
|
html += F("</option>");
|
|
}
|
|
html += F("</select>");
|
|
|
|
// Spreading Factor — dropdown 5-12
|
|
html += F("<label>Spreading Factor</label><select name='sf'>");
|
|
for (int sf = 5; sf <= 12; sf++) {
|
|
html += F("<option value='");
|
|
html += String(sf);
|
|
html += "'";
|
|
if (sf == cur_sf) html += F(" selected");
|
|
html += ">SF";
|
|
html += String(sf);
|
|
html += F("</option>");
|
|
}
|
|
html += F("</select>");
|
|
|
|
// Coding Rate — dropdown 5-8 (maps to 4/5 through 4/8)
|
|
html += F("<label>Coding Rate</label><select name='cr'>");
|
|
for (int cr = 5; cr <= 8; cr++) {
|
|
html += F("<option value='");
|
|
html += String(cr);
|
|
html += "'";
|
|
if (cr == cur_cr) html += F(" selected");
|
|
html += ">4/";
|
|
html += String(cr);
|
|
html += F("</option>");
|
|
}
|
|
html += F("</select>");
|
|
|
|
// TX Power
|
|
html += F("<label>TX Power (dBm)</label>");
|
|
html += F("<input name='txp' type='number' min='2' max='");
|
|
#ifdef PA_MAX_OUTPUT
|
|
html += String(PA_MAX_OUTPUT);
|
|
#else
|
|
html += "22";
|
|
#endif
|
|
html += F("' value='");
|
|
html += String(cur_txp);
|
|
html += F("'>");
|
|
|
|
#ifdef PA_MAX_OUTPUT
|
|
html += F("<p class='note'>Max output for this board: ");
|
|
html += String(PA_MAX_OUTPUT);
|
|
html += F(" dBm (with PA)</p>");
|
|
#endif
|
|
|
|
// ── IFAC (Interface Access Code) Section ──
|
|
html += F(
|
|
"<h2>🔒 Network Access (IFAC)</h2>"
|
|
"<p class='note'>Set a network name and/or passphrase to restrict LoRa interface access. "
|
|
"Only nodes with matching settings can communicate. Both fields are optional.</p>"
|
|
"<label>IFAC</label>"
|
|
"<select name='ifac_en'>"
|
|
);
|
|
html += F("<option value='0'");
|
|
if (!boundary_state.ifac_enabled) html += F(" selected");
|
|
html += F(">Disabled</option>");
|
|
html += F("<option value='1'");
|
|
if (boundary_state.ifac_enabled) html += F(" selected");
|
|
html += F(">Enabled</option>");
|
|
html += F("</select>");
|
|
|
|
html += F("<label>Network Name</label>");
|
|
html += F("<input name='ifac_name' maxlength='32' placeholder='e.g. MyNetwork' value='");
|
|
html += String(boundary_state.ifac_netname);
|
|
html += F("'>");
|
|
|
|
html += F("<label>Passphrase</label>");
|
|
html += F("<input name='ifac_pass' type='password' maxlength='32' placeholder='Shared secret' value='");
|
|
html += String(boundary_state.ifac_passphrase);
|
|
html += F("'>");
|
|
|
|
// ── Options Section ──
|
|
html += F(
|
|
"<h2>⚙ Options</h2>"
|
|
"<label>Display Blanking</label>"
|
|
"<select name='disp_blank'>"
|
|
);
|
|
|
|
// Read current blanking timeout from EEPROM (stored as minutes, 0 = never)
|
|
uint8_t cur_blank = 5;
|
|
if (EEPROM.read(eeprom_addr(ADDR_CONF_BSET)) == CONF_OK_BYTE) {
|
|
cur_blank = EEPROM.read(eeprom_addr(ADDR_CONF_DBLK));
|
|
}
|
|
|
|
uint8_t cur_rotation = EEPROM.read(eeprom_addr(ADDR_CONF_DROT));
|
|
if (cur_rotation > 3) {
|
|
cur_rotation = config_default_display_rotation();
|
|
}
|
|
|
|
static const uint8_t blank_vals[] = { 0, 1, 5, 10, 30, 60 };
|
|
static const char* blank_labels[] = { "Never", "1 minute", "5 minutes", "10 minutes", "30 minutes", "60 minutes" };
|
|
static const int blank_count = 6;
|
|
|
|
for (int i = 0; i < blank_count; i++) {
|
|
html += F("<option value='");
|
|
html += String(blank_vals[i]);
|
|
html += "'";
|
|
if (blank_vals[i] == cur_blank) html += F(" selected");
|
|
html += ">";
|
|
html += blank_labels[i];
|
|
html += F("</option>");
|
|
}
|
|
html += F("</select>");
|
|
html += F("<p class='note'>Turn off display after inactivity to save power</p>");
|
|
|
|
html += F("<label>Display Orientation</label><select name='disp_rot'>");
|
|
html += F("<option value='0'");
|
|
if (cur_rotation == 0) html += F(" selected");
|
|
html += F(">Landscape</option>");
|
|
html += F("<option value='1'");
|
|
if (cur_rotation == 1) html += F(" selected");
|
|
html += F(">Portrait</option>");
|
|
html += F("<option value='2'");
|
|
if (cur_rotation == 2) html += F(" selected");
|
|
html += F(">Landscape Flipped</option>");
|
|
html += F("<option value='3'");
|
|
if (cur_rotation == 3) html += F(" selected");
|
|
html += F(">Portrait Flipped</option>");
|
|
html += F("</select>");
|
|
html += F("<p class='note'>Choose the orientation that matches your OLED mounting. "
|
|
"Landscape modes place the two status panes side by side; portrait modes stack them.</p>");
|
|
|
|
// ── Submit ──
|
|
html += F(
|
|
"<button type='submit'>Save & Reboot</button>"
|
|
"</form></body></html>"
|
|
);
|
|
|
|
config_server->send(200, "text/html", html);
|
|
}
|
|
|
|
// ─── Handle POST /save ──────────────────────────────────────────────────────
|
|
|
|
static void config_handle_save() {
|
|
// ── WiFi STA credentials ──
|
|
String ssid = config_server->arg("ssid");
|
|
String psk = config_server->arg("psk");
|
|
|
|
// Write SSID to config EEPROM area
|
|
for (int i = 0; i < 32; i++) {
|
|
uint8_t c = (i < (int)ssid.length()) ? ssid[i] : 0x00;
|
|
EEPROM.write(config_addr(ADDR_CONF_SSID + i), c);
|
|
}
|
|
EEPROM.write(config_addr(ADDR_CONF_SSID + 32), 0x00);
|
|
|
|
// Write PSK
|
|
for (int i = 0; i < 32; i++) {
|
|
uint8_t c = (i < (int)psk.length()) ? psk[i] : 0x00;
|
|
EEPROM.write(config_addr(ADDR_CONF_PSK + i), c);
|
|
}
|
|
EEPROM.write(config_addr(ADDR_CONF_PSK + 32), 0x00);
|
|
|
|
// Set WiFi mode to STA
|
|
EEPROM.write(eeprom_addr(ADDR_CONF_WIFI), WR_WIFI_STA);
|
|
|
|
// Boundary mode always uses DHCP on the STA interface. Clear the legacy
|
|
// static IP and netmask slots so stale values from older firmware or tools
|
|
// cannot force a persistent static address.
|
|
for (int i = 0; i < 4; i++) {
|
|
EEPROM.write(config_addr(ADDR_CONF_IP + i), 0x00);
|
|
EEPROM.write(config_addr(ADDR_CONF_NM + i), 0x00);
|
|
}
|
|
|
|
// ── WiFi enable setting ──
|
|
boundary_state.wifi_enabled = (config_server->arg("wifi_en").toInt() == 1);
|
|
|
|
// ── Display blanking (EEPROM stores minutes, 0 = disabled) ──
|
|
int blank_minutes = config_server->arg("disp_blank").toInt();
|
|
if (blank_minutes <= 0) {
|
|
display_blanking_enabled = false;
|
|
eeprom_update(eeprom_addr(ADDR_CONF_BSET), CONF_OK_BYTE);
|
|
eeprom_update(eeprom_addr(ADDR_CONF_DBLK), 0);
|
|
} else {
|
|
uint8_t blank_val = (uint8_t)(blank_minutes > 255 ? 255 : blank_minutes);
|
|
display_blanking_enabled = true;
|
|
display_blanking_timeout = (uint32_t)blank_val * 60UL * 1000UL;
|
|
eeprom_update(eeprom_addr(ADDR_CONF_BSET), CONF_OK_BYTE);
|
|
eeprom_update(eeprom_addr(ADDR_CONF_DBLK), blank_val);
|
|
}
|
|
|
|
int display_rotation = config_server->arg("disp_rot").toInt();
|
|
if (display_rotation < 0 || display_rotation > 3) {
|
|
display_rotation = config_default_display_rotation();
|
|
}
|
|
eeprom_update(eeprom_addr(ADDR_CONF_DROT), (uint8_t)display_rotation);
|
|
|
|
// ── TCP backbone settings ──
|
|
boundary_state.tcp_mode = (uint8_t)config_server->arg("tcp_mode").toInt(); // 0=disabled, 1=client
|
|
if (boundary_state.tcp_mode > 1) boundary_state.tcp_mode = 0;
|
|
boundary_state.tcp_port = (uint16_t)config_server->arg("tcp_port").toInt();
|
|
if (boundary_state.tcp_port == 0) boundary_state.tcp_port = 4242;
|
|
|
|
String bb_host = config_server->arg("bb_host");
|
|
memset(boundary_state.backbone_host, 0, sizeof(boundary_state.backbone_host));
|
|
strncpy(boundary_state.backbone_host, bb_host.c_str(), sizeof(boundary_state.backbone_host) - 1);
|
|
|
|
boundary_state.backbone_port = (uint16_t)config_server->arg("bb_port").toInt();
|
|
if (boundary_state.backbone_port == 0) boundary_state.backbone_port = 4242;
|
|
|
|
// ── Local TCP server settings ──
|
|
boundary_state.ap_tcp_enabled = (config_server->arg("ap_tcp_en").toInt() == 1);
|
|
boundary_state.ap_tcp_port = (uint16_t)config_server->arg("ap_tcp_port").toInt();
|
|
if (boundary_state.ap_tcp_port == 0) boundary_state.ap_tcp_port = 4242;
|
|
|
|
// ── IFAC settings ──
|
|
boundary_state.ifac_enabled = (config_server->arg("ifac_en").toInt() == 1);
|
|
|
|
String ifac_name = config_server->arg("ifac_name");
|
|
memset(boundary_state.ifac_netname, 0, sizeof(boundary_state.ifac_netname));
|
|
strncpy(boundary_state.ifac_netname, ifac_name.c_str(), sizeof(boundary_state.ifac_netname) - 1);
|
|
|
|
String ifac_pass = config_server->arg("ifac_pass");
|
|
memset(boundary_state.ifac_passphrase, 0, sizeof(boundary_state.ifac_passphrase));
|
|
strncpy(boundary_state.ifac_passphrase, ifac_pass.c_str(), sizeof(boundary_state.ifac_passphrase) - 1);
|
|
|
|
// If IFAC is enabled but both fields are empty, disable it
|
|
if (boundary_state.ifac_enabled &&
|
|
boundary_state.ifac_netname[0] == '\0' &&
|
|
boundary_state.ifac_passphrase[0] == '\0') {
|
|
boundary_state.ifac_enabled = false;
|
|
}
|
|
|
|
// Save boundary config to EEPROM
|
|
boundary_save_config();
|
|
|
|
// ── LoRa radio settings ──
|
|
String freq_str = config_server->arg("freq");
|
|
double freq_mhz = freq_str.toDouble();
|
|
if (freq_mhz > 0) {
|
|
lora_freq = (uint32_t)(freq_mhz * 1000000.0);
|
|
}
|
|
|
|
String bw_str = config_server->arg("bw");
|
|
uint32_t bw_val = (uint32_t)bw_str.toInt();
|
|
if (bw_val > 0) lora_bw = bw_val;
|
|
|
|
int sf_val = config_server->arg("sf").toInt();
|
|
if (sf_val >= 5 && sf_val <= 12) lora_sf = sf_val;
|
|
|
|
int cr_val = config_server->arg("cr").toInt();
|
|
if (cr_val >= 5 && cr_val <= 8) lora_cr = cr_val;
|
|
|
|
int txp_val = config_server->arg("txp").toInt();
|
|
if (txp_val >= 2 && txp_val <= 30) lora_txp = txp_val;
|
|
|
|
// Save LoRa config to EEPROM (reuse existing eeprom_conf functions)
|
|
// Write directly since hw_ready may not be set yet
|
|
eeprom_update(eeprom_addr(ADDR_CONF_SF), lora_sf);
|
|
eeprom_update(eeprom_addr(ADDR_CONF_CR), lora_cr);
|
|
eeprom_update(eeprom_addr(ADDR_CONF_TXP), lora_txp);
|
|
eeprom_update(eeprom_addr(ADDR_CONF_BW) + 0, lora_bw >> 24);
|
|
eeprom_update(eeprom_addr(ADDR_CONF_BW) + 1, lora_bw >> 16);
|
|
eeprom_update(eeprom_addr(ADDR_CONF_BW) + 2, lora_bw >> 8);
|
|
eeprom_update(eeprom_addr(ADDR_CONF_BW) + 3, lora_bw);
|
|
eeprom_update(eeprom_addr(ADDR_CONF_FREQ) + 0, lora_freq >> 24);
|
|
eeprom_update(eeprom_addr(ADDR_CONF_FREQ) + 1, lora_freq >> 16);
|
|
eeprom_update(eeprom_addr(ADDR_CONF_FREQ) + 2, lora_freq >> 8);
|
|
eeprom_update(eeprom_addr(ADDR_CONF_FREQ) + 3, lora_freq);
|
|
eeprom_update(eeprom_addr(ADDR_CONF_OK), CONF_OK_BYTE);
|
|
|
|
EEPROM.commit();
|
|
|
|
// ── Send confirmation page ──
|
|
String ok = F(
|
|
"<!DOCTYPE html><html><head>"
|
|
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
|
"<title>Saved</title>"
|
|
"<style>"
|
|
"body{font-family:sans-serif;background:#1a1a2e;color:#e0e0e0;padding:40px;"
|
|
"display:flex;align-items:center;justify-content:center;min-height:80vh;}"
|
|
".ok{background:#16213e;padding:30px;border-radius:12px;text-align:center;max-width:400px;}"
|
|
"h1{color:#4caf50;margin-bottom:16px;}"
|
|
"p{color:#aaa;}"
|
|
"</style></head><body>"
|
|
"<div class='ok'>"
|
|
"<h1>✅ Configuration Saved</h1>"
|
|
"<p>Device will reboot in 3 seconds and connect to your WiFi network.</p>"
|
|
"<p style='color:#666;font-size:0.85em;'>If the device cannot connect, hold the button for 5+ seconds to re-enter setup.</p>"
|
|
"</div></body></html>"
|
|
);
|
|
config_server->send(200, "text/html", ok);
|
|
|
|
// Give the response time to send
|
|
delay(3000);
|
|
|
|
// Reboot
|
|
ESP.restart();
|
|
}
|
|
|
|
// ─── Captive Portal redirect ─────────────────────────────────────────────────
|
|
static void config_handle_redirect() {
|
|
config_server->sendHeader("Location", "http://10.0.0.1/", true);
|
|
config_server->send(302, "text/plain", "Redirecting to setup...");
|
|
}
|
|
|
|
// ─── Check if config is needed ───────────────────────────────────────────────
|
|
bool boundary_needs_config() {
|
|
// If the RTNode app marker is missing, this node was either never
|
|
// configured by RTNode or was flashed from a different firmware family
|
|
// such as stock RNode. Force the portal so RTNode can claim and rewrite
|
|
// its persisted settings explicitly.
|
|
if (!boundary_app_marker_valid()) {
|
|
return true;
|
|
}
|
|
|
|
// Check if WiFi SSID is configured
|
|
char ssid[33];
|
|
for (int i = 0; i < 32; i++) {
|
|
ssid[i] = EEPROM.read(config_addr(ADDR_CONF_SSID + i));
|
|
if (ssid[i] == (char)0xFF) ssid[i] = '\0';
|
|
}
|
|
ssid[32] = '\0';
|
|
|
|
// Also check boundary mode enable flag
|
|
uint8_t bmode = EEPROM.read(config_addr(ADDR_CONF_BMODE));
|
|
|
|
// Need config if no SSID set and boundary not yet configured
|
|
if (ssid[0] == '\0' && bmode != BOUNDARY_ENABLE_BYTE) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ─── Start Config Portal ─────────────────────────────────────────────────────
|
|
void config_portal_start() {
|
|
if (config_portal_active) return;
|
|
|
|
Serial.println("[Config] Starting configuration portal...");
|
|
|
|
// Stop any existing WiFi
|
|
WiFi.softAPdisconnect(true);
|
|
WiFi.disconnect(true, true);
|
|
WiFi.mode(WIFI_MODE_NULL);
|
|
delay(100);
|
|
|
|
// Start AP
|
|
WiFi.mode(WIFI_AP);
|
|
WiFi.softAP(CONFIG_AP_SSID, NULL); // Open AP for easy setup
|
|
delay(150);
|
|
|
|
IPAddress ap_addr(10, 0, 0, 1);
|
|
IPAddress ap_mask(255, 255, 255, 0);
|
|
WiFi.softAPConfig(ap_addr, ap_addr, ap_mask);
|
|
|
|
Serial.print("[Config] AP started: ");
|
|
Serial.println(CONFIG_AP_SSID);
|
|
Serial.print("[Config] IP: ");
|
|
Serial.println(WiFi.softAPIP());
|
|
|
|
// Start DNS server for captive portal (redirect all domains to us)
|
|
config_dns = new DNSServer();
|
|
config_dns->start(DNS_PORT, "*", ap_addr);
|
|
|
|
// Start web server
|
|
config_server = new WebServer(HTTP_PORT);
|
|
config_server->on("/", HTTP_GET, config_send_html);
|
|
config_server->on("/save", HTTP_POST, config_handle_save);
|
|
config_server->onNotFound(config_handle_redirect); // Captive portal catch-all
|
|
config_server->begin();
|
|
|
|
config_portal_active = true;
|
|
|
|
Serial.println("[Config] Portal ready — connect to WiFi: " + String(CONFIG_AP_SSID));
|
|
|
|
#if HAS_DISPLAY
|
|
if (disp_ready) {
|
|
// Show config mode on display
|
|
stat_area.fillScreen(SSD1306_BLACK);
|
|
stat_area.setCursor(0, 0);
|
|
stat_area.println("CONFIG MODE");
|
|
stat_area.println("");
|
|
stat_area.println("Connect to:");
|
|
stat_area.println(CONFIG_AP_SSID);
|
|
stat_area.println("");
|
|
stat_area.println("Open browser");
|
|
stat_area.println("http://10.0.0.1");
|
|
display.clearDisplay();
|
|
display.drawBitmap(0, 0, stat_area.getBuffer(), stat_area.width(), stat_area.height(), SSD1306_WHITE, SSD1306_BLACK);
|
|
display.display();
|
|
}
|
|
#endif
|
|
// Headless: LED ramp will be driven from the WCC portal loop
|
|
if (headless_mode) {
|
|
Serial.println("[Config] Headless mode — LED will breathe during config portal");
|
|
}
|
|
}
|
|
|
|
// ─── Stop Config Portal ──────────────────────────────────────────────────────
|
|
void config_portal_stop() {
|
|
if (!config_portal_active) return;
|
|
|
|
Serial.println("[Config] Stopping configuration portal");
|
|
|
|
if (config_server) {
|
|
config_server->stop();
|
|
delete config_server;
|
|
config_server = nullptr;
|
|
}
|
|
if (config_dns) {
|
|
config_dns->stop();
|
|
delete config_dns;
|
|
config_dns = nullptr;
|
|
}
|
|
|
|
WiFi.softAPdisconnect(true);
|
|
WiFi.mode(WIFI_MODE_NULL);
|
|
config_portal_active = false;
|
|
}
|
|
|
|
// ─── Portal Loop — call from main loop() ─────────────────────────────────────
|
|
void config_portal_loop() {
|
|
if (!config_portal_active) return;
|
|
if (config_dns) config_dns->processNextRequest();
|
|
if (config_server) config_server->handleClient();
|
|
}
|
|
|
|
// ─── Is portal active? ──────────────────────────────────────────────────────
|
|
bool config_portal_is_active() {
|
|
return config_portal_active;
|
|
}
|
|
|
|
#endif // BOUNDARY_MODE
|
|
#endif // BOUNDARY_CONFIG_H
|