// Copyright (C) 2026, Boundary Mode Extension // Based on microReticulum_Firmware by Mark Qvist // // BoundaryConfig.h — Captive-portal web configuration for Boundary Mode. // 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 #include #include // ─── 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]); // ─── 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 = 914875000; // 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 = 5; // CR 4/5 default // Build the HTML page String html = F( "" "" "RNode Boundary Setup" "" "

📡 RNode Boundary Node

" "
" ); // ── WiFi STA Section ── html += F( "

📶 WiFi Network

" "" ""); html += F( "" "" "" ""); // ── TCP Backbone Section ── html += F( "

🌐 TCP Backbone

" "" ""); html += F(""); html += F(""); html += F(""); html += F(""); // ── Local TCP Server Section ── html += F( "

📡 Local TCP Server (optional)

" "

Run a TCP server on the same WiFi network so local devices can connect. " "Uses Access Point mode (does not forward announces).

" "" ""); html += F(""); html += F(""); // ── LoRa Radio Section ── html += F( "

📻 LoRa Radio

" ); // Frequency — show in MHz for human-friendliness char freq_str[16]; dtostrf((double)cur_freq / 1000000.0, 1, 3, freq_str); html += F(""); html += F(""); html += F("

e.g. 914.875, 868.000, 433.000

"); // Bandwidth — dropdown html += F(""); // Spreading Factor — dropdown 6-12 html += F(""); // Coding Rate — dropdown 5-8 (maps to 4/5 through 4/8) html += F(""); // TX Power html += F(""); html += F(""); #ifdef PA_MAX_OUTPUT html += F("

Max output for this board: "); html += String(PA_MAX_OUTPUT); html += F(" dBm (with PA)

"); #endif // ── IFAC (Interface Access Code) Section ── html += F( "

🔒 Network Access (IFAC)

" "

Set a network name and/or passphrase to restrict LoRa interface access. " "Only nodes with matching settings can communicate. Both fields are optional.

" "" ""); html += F(""); html += F(""); html += F(""); html += F(""); // ── Options Section ── html += F( "

⚙ Options

" "" ""); html += F("

Turn off display after inactivity to save power

"); // ── Submit ── html += F( "" "
" ); 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); // ── 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); } // ── 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( "" "" "Saved" "" "
" "

✅ Configuration Saved

" "

Device will reboot in 3 seconds and connect to your WiFi network.

" "

If the device cannot connect, hold the button for 5+ seconds to re-enter setup.

" "
" ); 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() { // 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