Harden WiFi and LR1110 recovery
This commit is contained in:
1 parent
9bec10b41a
commit
44ec918303
20 files changed
+1835
-1240
No files matched your search
@@ -1595,6 +1595,10 @@ bool MyMesh::isWebConfigSetupActive() const {
|
||||
&& !_webconfig->isStopping();
|
||||
}
|
||||
|
||||
bool MyMesh::isWebConfigWiFiRecoveryActive() const {
|
||||
return _webconfig && _webconfig->isSavedWiFiRecoveryActive();
|
||||
}
|
||||
|
||||
void MyMesh::rebootNow() {
|
||||
board.reboot();
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ public:
|
||||
void stopWebConfig();
|
||||
void serviceWebConfig();
|
||||
bool isWebConfigSetupActive() const;
|
||||
bool isWebConfigWiFiRecoveryActive() const;
|
||||
|
||||
void getNodeSnapshot(WebConfigServer::NodeSnapshot& snapshot) override;
|
||||
void execCommand(char* cmd, char* reply) override;
|
||||
|
||||
@@ -45,6 +45,7 @@ MultiSerialInterface interface_manager;
|
||||
// include esp32 wifi interface
|
||||
#include <helpers/esp32/SerialWifiInterface.h>
|
||||
#include <helpers/WiFiSetupPortal.h>
|
||||
#include <helpers/WiFiReconnectPolicy.h>
|
||||
SerialWifiInterface wifi_interface;
|
||||
#ifndef WIFI_PWD
|
||||
#define WIFI_PWD ""
|
||||
@@ -120,9 +121,9 @@ void halt() {
|
||||
#if defined(ESP32) && defined(WIFI_SSID)
|
||||
static const unsigned long WIFI_SETUP_FALLBACK_MS = 120000UL;
|
||||
static const char COMPANION_WIFI_SETUP_AP[] = "MeshCore-Setup";
|
||||
bool wifi_needs_reconnect = false;
|
||||
unsigned long last_wifi_reconnect_attempt = 0;
|
||||
unsigned long wifi_disconnected_since = 0;
|
||||
WiFiReconnectPolicy::Tracker wifi_reconnect_tracker;
|
||||
bool wifi_setup_attempted = false;
|
||||
unsigned long last_wifi_setup_attempt = 0;
|
||||
bool wifi_setup_recovery_mode = false;
|
||||
static char configured_wifi_ssid[32];
|
||||
static char configured_wifi_password[64];
|
||||
@@ -139,7 +140,7 @@ void halt() {
|
||||
companion_setup_display->turnOn();
|
||||
companion_setup_display->startFrame();
|
||||
companion_setup_display->setTextSize(1);
|
||||
companion_setup_display->setColor(DisplayDriver::LIGHT);
|
||||
companion_setup_display->setColor(UIColor::primary_txt);
|
||||
|
||||
char setup_ssid[33] = {0};
|
||||
char setup_ip[16] = {0};
|
||||
@@ -335,16 +336,10 @@ void setup() {
|
||||
|
||||
WiFi.onEvent([](WiFiEvent_t event, WiFiEventInfo_t info){
|
||||
if (event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) {
|
||||
WIFI_DEBUG_PRINTLN("WiFi disconnected. Flagging for reconnect...");
|
||||
wifi_needs_reconnect = true;
|
||||
if (wifi_disconnected_since == 0) {
|
||||
wifi_disconnected_since = millis() ? millis() : 1;
|
||||
}
|
||||
WIFI_DEBUG_PRINTLN("WiFi disconnected; automatic recovery is active");
|
||||
} else if (event == ARDUINO_EVENT_WIFI_STA_GOT_IP) {
|
||||
WIFI_DEBUG_PRINTLN("connected! IP %s (companion app on :%d)",
|
||||
WiFi.localIP().toString().c_str(), TCP_PORT);
|
||||
wifi_needs_reconnect = false;
|
||||
wifi_disconnected_since = 0;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -361,7 +356,7 @@ void setup() {
|
||||
|
||||
if (have_wifi) {
|
||||
WiFi.mode(WIFI_STA);
|
||||
wifi_disconnected_since = millis() ? millis() : 1;
|
||||
wifi_reconnect_tracker.noteDisconnected(millis());
|
||||
WiFi.begin(configured_wifi_ssid, configured_wifi_password);
|
||||
}
|
||||
#ifndef WITH_WEBCONFIG
|
||||
@@ -512,10 +507,18 @@ void loop() {
|
||||
#ifdef ENABLE_OTA
|
||||
ota_console_loop(); // service the OTA text console (port 5002)
|
||||
#endif
|
||||
const unsigned long wifi_now = millis();
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
wifi_disconnected_since = 0;
|
||||
wifi_reconnect_tracker.noteConnected();
|
||||
wifi_setup_attempted = false;
|
||||
#ifdef WITH_WEBCONFIG
|
||||
if (wifi_setup_recovery_mode && the_mesh.isWebConfigSetupActive()) {
|
||||
// startAutoMode() can raise the setup AP after its own short connection
|
||||
// timeout, before the main-loop fallback marks recovery mode. A successful
|
||||
// saved-SSID retry should close either kind of recovery AP.
|
||||
if (configured_wifi_ssid[0] && the_mesh.isWebConfigSetupActive()
|
||||
&& (wifi_setup_recovery_mode
|
||||
|| the_mesh.isWebConfigWiFiRecoveryActive())) {
|
||||
WiFi.setAutoReconnect(true);
|
||||
the_mesh.stopWebConfig();
|
||||
wifi_setup_recovery_mode = false;
|
||||
}
|
||||
@@ -530,51 +533,69 @@ void loop() {
|
||||
wifi_setup_recovery_mode = false;
|
||||
}
|
||||
} else if (configured_wifi_ssid[0]) {
|
||||
unsigned long now = millis();
|
||||
if (wifi_disconnected_since == 0) wifi_disconnected_since = now ? now : 1;
|
||||
wifi_reconnect_tracker.noteDisconnected(wifi_now);
|
||||
if (!wifi_setup_recovery_mode
|
||||
#ifdef WITH_WEBCONFIG
|
||||
&& !the_mesh.isWebConfigSetupActive()
|
||||
#else
|
||||
&& !wifiSetupPortal().isActive()
|
||||
#endif
|
||||
&& now - wifi_disconnected_since >= WIFI_SETUP_FALLBACK_MS) {
|
||||
&& wifi_reconnect_tracker.disconnectedFor(
|
||||
wifi_now, WIFI_SETUP_FALLBACK_MS)
|
||||
&& (!wifi_setup_attempted
|
||||
|| WiFiReconnectPolicy::elapsedMs(
|
||||
wifi_now, last_wifi_setup_attempt) >= WIFI_SETUP_FALLBACK_MS)) {
|
||||
wifi_setup_attempted = true;
|
||||
last_wifi_setup_attempt = wifi_now;
|
||||
#ifdef WITH_WEBCONFIG
|
||||
if (WebConfigServer::loadEnabled(true)) {
|
||||
char web_reply[160];
|
||||
if (the_mesh.startWebConfig(true, web_reply)) {
|
||||
wifi_setup_recovery_mode = true;
|
||||
WIFI_DEBUG_PRINTLN("WiFi unavailable for two minutes; %s", web_reply);
|
||||
} else {
|
||||
wifi_disconnected_since = now;
|
||||
}
|
||||
}
|
||||
#else
|
||||
if (wifiSetupPortal().begin(COMPANION_WIFI_SETUP_AP, saveCompanionWiFi, nullptr)) {
|
||||
wifiSetupPortal().configureRecovery(
|
||||
configured_wifi_ssid, configured_wifi_password, WIFI_SETUP_FALLBACK_MS);
|
||||
configured_wifi_ssid, configured_wifi_password,
|
||||
WiFiReconnectPolicy::kRetryIntervalMs,
|
||||
WiFiReconnectPolicy::kRetryIntervalMs - WIFI_SETUP_FALLBACK_MS);
|
||||
wifi_setup_recovery_mode = true;
|
||||
WIFI_DEBUG_PRINTLN("WiFi unavailable for two minutes; setup AP started");
|
||||
} else {
|
||||
// Avoid retrying portal creation on every pass through loop().
|
||||
wifi_disconnected_since = now;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
// Safely attempt to reconnect every 10 seconds if flagged
|
||||
if (
|
||||
|
||||
// The ESP stack normally reconnects by itself. If it gets wedged after an AP
|
||||
// outage, reassert the saved credentials every five minutes. WebConfig's
|
||||
// setup AP uses AP+STA mode, so the station retry can run without taking the
|
||||
// recovery page down. The legacy portal owns its own identical retry timer.
|
||||
const bool reconnect_owned_here =
|
||||
#ifdef WITH_WEBCONFIG
|
||||
!the_mesh.isWebConfigSetupActive()
|
||||
|| wifi_setup_recovery_mode
|
||||
|| the_mesh.isWebConfigWiFiRecoveryActive();
|
||||
#else
|
||||
!wifiSetupPortal().isActive()
|
||||
!wifiSetupPortal().isActive();
|
||||
#endif
|
||||
&& wifi_needs_reconnect
|
||||
&& (millis() - last_wifi_reconnect_attempt > 10000)) {
|
||||
WIFI_DEBUG_PRINTLN("Attempting manual WiFi reconnect...");
|
||||
WiFi.disconnect();
|
||||
WiFi.reconnect();
|
||||
last_wifi_reconnect_attempt = millis();
|
||||
if (configured_wifi_ssid[0] && reconnect_owned_here
|
||||
&& WiFi.status() != WL_CONNECTED
|
||||
&& wifi_reconnect_tracker.retryDue(wifi_now)) {
|
||||
WIFI_DEBUG_PRINTLN("WiFi still unavailable; retrying saved SSID");
|
||||
wifi_reconnect_tracker.noteAttempt(wifi_now);
|
||||
#ifdef WITH_WEBCONFIG
|
||||
if (!the_mesh.isWebConfigSetupActive()) {
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.setAutoReconnect(true);
|
||||
}
|
||||
#else
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.setAutoReconnect(true);
|
||||
#endif
|
||||
WiFi.disconnect(false, false);
|
||||
WiFi.begin(configured_wifi_ssid, configured_wifi_password);
|
||||
}
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
the_mesh.serviceMQTT(configured_wifi_ssid, configured_wifi_password);
|
||||
|
||||
@@ -100,10 +100,10 @@ void UITask::renderCurrScreen() {
|
||||
#ifdef WITH_WEBCONFIG
|
||||
if (WebConfigServer::isRebootPending()) {
|
||||
_display->setTextSize(1);
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
_display->setColor(UIColor::corp_blue);
|
||||
_display->setCursor(0, 14);
|
||||
_display->print("Config saved!");
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->setCursor(0, 30);
|
||||
_display->print("Rebooting...");
|
||||
return;
|
||||
@@ -111,19 +111,19 @@ void UITask::renderCurrScreen() {
|
||||
char wc_ssid[33], wc_ip[16];
|
||||
if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) {
|
||||
_display->setTextSize(1);
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
_display->setColor(UIColor::corp_blue);
|
||||
_display->setCursor(0, 0);
|
||||
_display->print("WebUI WiFi Setup");
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->setCursor(0, 14);
|
||||
_display->print("Join WiFi:");
|
||||
_display->setColor(DisplayDriver::YELLOW);
|
||||
_display->setColor(UIColor::warning_txt);
|
||||
_display->setCursor(6, 24);
|
||||
_display->print(wc_ssid);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->setCursor(0, 40);
|
||||
_display->print("Then browse to:");
|
||||
_display->setColor(DisplayDriver::YELLOW);
|
||||
_display->setColor(UIColor::warning_txt);
|
||||
_display->setCursor(6, 50);
|
||||
_display->print(wc_ip);
|
||||
return;
|
||||
|
||||
@@ -81,10 +81,10 @@ void UITask::renderCurrScreen() {
|
||||
#ifdef WITH_WEBCONFIG
|
||||
if (WebConfigServer::isRebootPending()) {
|
||||
_display->setTextSize(1);
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
_display->setColor(UIColor::corp_blue);
|
||||
_display->setCursor(0, 14);
|
||||
_display->print("Config saved!");
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->setCursor(0, 30);
|
||||
_display->print("Rebooting...");
|
||||
return;
|
||||
@@ -92,19 +92,19 @@ void UITask::renderCurrScreen() {
|
||||
char wc_ssid[33], wc_ip[16];
|
||||
if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) {
|
||||
_display->setTextSize(1);
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
_display->setColor(UIColor::corp_blue);
|
||||
_display->setCursor(0, 0);
|
||||
_display->print("WebUI WiFi Setup");
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->setCursor(0, 14);
|
||||
_display->print("Join WiFi:");
|
||||
_display->setColor(DisplayDriver::YELLOW);
|
||||
_display->setColor(UIColor::warning_txt);
|
||||
_display->setCursor(6, 24);
|
||||
_display->print(wc_ssid);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
_display->setCursor(0, 40);
|
||||
_display->print("Then browse to:");
|
||||
_display->setColor(DisplayDriver::YELLOW);
|
||||
_display->setColor(UIColor::warning_txt);
|
||||
_display->setCursor(6, 50);
|
||||
_display->print(wc_ip);
|
||||
return;
|
||||
@@ -131,7 +131,7 @@ void UITask::renderCurrScreen() {
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
IPAddress ip = WiFi.localIP();
|
||||
_display->setCursor(0, 40);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setColor(UIColor::primary_txt);
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
||||
_display->print(tmp);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ static const int kMaxBatch = 24; // .h:90 MAX_BATCH
|
||||
static const uint32_t kDrainPacingMs = 25; // .cpp:296 inter-command gap
|
||||
static const uint32_t kRebootFallbackMs = 30000; // .cpp:331 drain-finish fallback
|
||||
static const uint32_t kRebootConfirmMs = 3000; // .cpp:784 first result-read arm
|
||||
static const uint32_t kSetupWiFiConnectTimeoutMs = 20000;
|
||||
static const uint32_t kSetupHandoffRebootConfirmMs = 1000;
|
||||
static const uint32_t kStopWarnMs = 10000; // .h:95 STOP_WARN_MS
|
||||
|
||||
// The batch lifecycle. A fresh POST moves Idle->Pending; the drainer moves
|
||||
@@ -134,6 +136,23 @@ static inline uint32_t finishRebootAt(bool batch_reboot, bool batch_all_ok, uint
|
||||
return (batch_reboot && batch_all_ok) ? scheduleAt(now, kRebootFallbackMs) : 0u;
|
||||
}
|
||||
|
||||
// A successful setup-portal save that changes station credentials is held in
|
||||
// Pending while AP+STA joins the selected network. That makes the DHCP address
|
||||
// available to the captive page before its setup AP disappears. Other saves
|
||||
// keep the ordinary batch/reboot path.
|
||||
static inline bool shouldStartSetupWiFiHandoff(bool setup_mode,
|
||||
bool batch_reboot,
|
||||
bool batch_all_ok,
|
||||
bool wifi_credentials_changed,
|
||||
bool has_wifi_ssid) {
|
||||
return setup_mode && batch_reboot && batch_all_ok
|
||||
&& wifi_credentials_changed && has_wifi_ssid;
|
||||
}
|
||||
|
||||
static inline uint32_t setupWiFiConnectDeadline(uint32_t now) {
|
||||
return scheduleAt(now, kSetupWiFiConnectTimeoutMs);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Result read (.cpp:738-786).
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -168,6 +187,12 @@ static inline uint32_t confirmRebootAt(uint32_t now) {
|
||||
return scheduleAt(now, kRebootConfirmMs);
|
||||
}
|
||||
|
||||
// The browser retains the rendered handoff page after the AP disappears, so
|
||||
// only a brief response-flush delay is needed before rebooting.
|
||||
static inline uint32_t confirmSetupHandoffRebootAt(uint32_t now) {
|
||||
return scheduleAt(now, kSetupHandoffRebootConfirmMs);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Reboot fire (.cpp:262-265) and isRebootPending (.cpp:70-74).
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// Pure, platform-independent timing state for the explicit WiFi reconnect
|
||||
// fallback. The platform's automatic reconnect remains enabled where
|
||||
// appropriate; this tracker guarantees that a stuck station is kicked with
|
||||
// the saved credentials every five minutes while the link remains down.
|
||||
namespace WiFiReconnectPolicy {
|
||||
|
||||
static const uint32_t kRetryIntervalMs = 5UL * 60UL * 1000UL;
|
||||
|
||||
static inline uint32_t elapsedMs(uint32_t now, uint32_t then) {
|
||||
return now - then;
|
||||
}
|
||||
|
||||
class Tracker {
|
||||
public:
|
||||
Tracker() : _tracking(false), _disconnected_since(0), _last_attempt(0) {}
|
||||
|
||||
void noteConnected() {
|
||||
_tracking = false;
|
||||
_disconnected_since = 0;
|
||||
_last_attempt = 0;
|
||||
}
|
||||
|
||||
// The first observation of a disconnected station starts both timers. This
|
||||
// treats the current/initial association as the first attempt and schedules
|
||||
// the explicit fallback five minutes later.
|
||||
void noteDisconnected(uint32_t now) {
|
||||
if (_tracking) return;
|
||||
_tracking = true;
|
||||
_disconnected_since = now;
|
||||
_last_attempt = now;
|
||||
}
|
||||
|
||||
bool retryDue(uint32_t now) const {
|
||||
return _tracking
|
||||
&& elapsedMs(now, _disconnected_since) >= kRetryIntervalMs
|
||||
&& elapsedMs(now, _last_attempt) >= kRetryIntervalMs;
|
||||
}
|
||||
|
||||
void noteAttempt(uint32_t now) {
|
||||
if (!_tracking) noteDisconnected(now);
|
||||
_last_attempt = now;
|
||||
}
|
||||
|
||||
bool disconnectedFor(uint32_t now, uint32_t duration_ms) const {
|
||||
return _tracking && elapsedMs(now, _disconnected_since) >= duration_ms;
|
||||
}
|
||||
|
||||
bool isTracking() const { return _tracking; }
|
||||
|
||||
private:
|
||||
bool _tracking;
|
||||
uint32_t _disconnected_since;
|
||||
uint32_t _last_attempt;
|
||||
};
|
||||
|
||||
} // namespace WiFiReconnectPolicy
|
||||
@@ -14,7 +14,9 @@ namespace {
|
||||
static const IPAddress SETUP_IP(192, 168, 4, 1);
|
||||
static const IPAddress SETUP_MASK(255, 255, 255, 0);
|
||||
static const uint32_t CONNECT_TIMEOUT_MS = 20000;
|
||||
static const uint32_t AP_CLOSE_DELAY_MS = 5000;
|
||||
// The rendered success page remains in the browser after the setup SSID
|
||||
// disappears; allow one second for the HTTP response to flush first.
|
||||
static const uint32_t AP_CLOSE_DELAY_MS = 1000;
|
||||
|
||||
static const char SETUP_PAGE[] = R"HTML(<!doctype html>
|
||||
<html><head><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
@@ -170,13 +172,15 @@ static void handleSave(PortalImpl* impl, WiFiClient& client, const char* body) {
|
||||
"<p>MeshCore joined the selected network.</p><p>IP address: <strong>%s</strong></p>"
|
||||
"<p>Reconnect this phone or computer to that WiFi network, then open "
|
||||
"<strong>http://%s/</strong> to finish MQTT setup.</p>"
|
||||
"<p>The temporary setup access point will close shortly.</p>",
|
||||
"<p>The setup SSID will close now; this page and address will remain visible.</p>",
|
||||
ip.c_str(), ip.c_str());
|
||||
#else
|
||||
snprintf(response, sizeof(response),
|
||||
"<!doctype html><meta name=viewport content='width=device-width'><h2>Connected</h2>"
|
||||
"<p>MeshCore joined the WiFi network.</p><p>IP address: <strong>%s</strong></p>"
|
||||
"<p>The setup access point will close shortly.</p>", ip.c_str());
|
||||
"<p>MeshCore joined the WiFi network.</p>"
|
||||
"<p>New network address: <strong>http://%s/</strong></p>"
|
||||
"<p>The setup SSID will close now; this page and address will remain visible.</p>",
|
||||
ip.c_str());
|
||||
#endif
|
||||
sendResponse(client, 200, "OK", "text/html", response);
|
||||
Serial.printf("WiFi setup: connected; IP %s\n", ip.c_str());
|
||||
@@ -318,7 +322,8 @@ bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, voi
|
||||
}
|
||||
|
||||
void WiFiSetupPortal::configureRecovery(const char* ssid, const char* password,
|
||||
uint32_t interval_ms) {
|
||||
uint32_t interval_ms,
|
||||
uint32_t initial_delay_ms) {
|
||||
PortalImpl* impl = static_cast<PortalImpl*>(_impl);
|
||||
if (!impl) return;
|
||||
impl->recovery_ssid[0] = 0;
|
||||
@@ -326,7 +331,8 @@ void WiFiSetupPortal::configureRecovery(const char* ssid, const char* password,
|
||||
impl->recovery_password[sizeof(impl->recovery_password) - 1] = 0;
|
||||
impl->recovery_interval_ms = interval_ms;
|
||||
impl->recovery_connecting = false;
|
||||
impl->next_recovery_at = millis() + interval_ms;
|
||||
impl->next_recovery_at = millis()
|
||||
+ (initial_delay_ms ? initial_delay_ms : interval_ms);
|
||||
if (ssid) {
|
||||
strncpy(impl->recovery_ssid, ssid, sizeof(impl->recovery_ssid) - 1);
|
||||
impl->recovery_ssid[sizeof(impl->recovery_ssid) - 1] = 0;
|
||||
|
||||
@@ -14,7 +14,11 @@ public:
|
||||
// Starts an open setup AP and captive HTTP form. The AP remains available
|
||||
// after a failed association and shuts down shortly after a successful one.
|
||||
bool begin(const char* ap_name, SaveCallback save_callback, void* context);
|
||||
void configureRecovery(const char* ssid, const char* password, uint32_t interval_ms);
|
||||
// Retry interval is the steady-state cadence. initial_delay_ms defaults to
|
||||
// that interval, but callers that opened the portal partway through an
|
||||
// outage can preserve the original reconnect schedule with a shorter delay.
|
||||
void configureRecovery(const char* ssid, const char* password,
|
||||
uint32_t interval_ms, uint32_t initial_delay_ms = 0);
|
||||
|
||||
bool isActive() const { return _active; }
|
||||
IPAddress apIP() const { return IPAddress(192, 168, 4, 1); }
|
||||
|
||||
+1166
-1147
File diff suppressed because it is too large.
Load diff
@@ -458,6 +458,9 @@ bool WebConfigServer::startSetupMode(char reply[]) {
|
||||
strcpy(reply, "Err: webconfig busy");
|
||||
return false;
|
||||
}
|
||||
_retry_saved_wifi_in_setup = false;
|
||||
_setup_reconnect_in_progress = false;
|
||||
_setup_reconnect_deadline = 0;
|
||||
// AP_STA (not pure AP) so the WiFi scan for the SSID picker works while
|
||||
// the AP is up. STA stays unconnected - the bridge won't touch WiFi
|
||||
// while wifi_ssid is empty, and `start webconfig ap` requires it stopped.
|
||||
@@ -506,6 +509,10 @@ bool WebConfigServer::startLanMode(char reply[]) {
|
||||
return false;
|
||||
}
|
||||
_mode = MODE_LAN;
|
||||
_wifi_reconnect_tracker.noteConnected();
|
||||
_retry_saved_wifi_in_setup = false;
|
||||
_setup_reconnect_in_progress = false;
|
||||
_setup_reconnect_deadline = 0;
|
||||
createServer();
|
||||
_last_activity = millis();
|
||||
|
||||
@@ -530,6 +537,10 @@ bool WebConfigServer::startAutoMode(char reply[]) {
|
||||
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.setAutoReconnect(true);
|
||||
_retry_saved_wifi_in_setup = false;
|
||||
_setup_reconnect_in_progress = false;
|
||||
_setup_reconnect_deadline = 0;
|
||||
_wifi_reconnect_tracker.noteDisconnected(millis());
|
||||
WiFi.begin(_wifi_ssid, _wifi_password);
|
||||
wifi_ps_type_t ps_mode = _wifi_power_save == 1 ? WIFI_PS_NONE
|
||||
: _wifi_power_save == 2 ? WIFI_PS_MAX_MODEM
|
||||
@@ -602,10 +613,16 @@ void WebConfigServer::finalizeTeardown() {
|
||||
_stop_warn_at = 0;
|
||||
_stop_warned = false;
|
||||
_connect_deadline = 0;
|
||||
_retry_saved_wifi_in_setup = false;
|
||||
_setup_reconnect_in_progress = false;
|
||||
_setup_reconnect_deadline = 0;
|
||||
_reboot_at = 0;
|
||||
_batch_state = BATCH_IDLE;
|
||||
_batch_next = 0;
|
||||
_batch_reboot_armed = false;
|
||||
_setup_wifi_handoff_pending = false;
|
||||
_setup_wifi_handoff_deadline = 0;
|
||||
_setup_wifi_handoff_ip[0] = 0;
|
||||
_cli_state = CLI_IDLE;
|
||||
_cli_reqid[0] = 0;
|
||||
_cli_command[0] = 0;
|
||||
@@ -636,6 +653,7 @@ void WebConfigServer::tick(uint32_t now) {
|
||||
|
||||
if (_mode == MODE_CONNECTING) {
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
_wifi_reconnect_tracker.noteConnected();
|
||||
_mode = MODE_LAN;
|
||||
createServer();
|
||||
_connect_deadline = 0;
|
||||
@@ -643,20 +661,91 @@ void WebConfigServer::tick(uint32_t now) {
|
||||
Serial.printf("WebConfig ready: http://%s/\n", WiFi.localIP().toString().c_str());
|
||||
} else if (_connect_deadline && (int32_t)(now - _connect_deadline) >= 0) {
|
||||
Serial.printf("WebConfig: WiFi '%s' unavailable; opening setup AP\n", _wifi_ssid);
|
||||
const bool retry_saved_wifi = _wifi_ssid[0] != 0;
|
||||
WiFi.disconnect(true);
|
||||
_mode = MODE_OFF;
|
||||
_connect_deadline = 0;
|
||||
char ignored[160];
|
||||
startSetupMode(ignored);
|
||||
if (startSetupMode(ignored)) {
|
||||
_retry_saved_wifi_in_setup = retry_saved_wifi;
|
||||
}
|
||||
Serial.println(ignored);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// MQTT and companion runtimes manage their own station connection. For
|
||||
// standalone WiFi/WebUI builds, do not rely solely on ESP auto-reconnect:
|
||||
// after five minutes offline, explicitly reassert the saved credentials and
|
||||
// repeat at that interval until the router returns.
|
||||
if (_mode == MODE_LAN && _owns_wifi && _wifi_ssid[0]) {
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
_wifi_reconnect_tracker.noteConnected();
|
||||
} else {
|
||||
_wifi_reconnect_tracker.noteDisconnected(now);
|
||||
if (_wifi_reconnect_tracker.retryDue(now)) {
|
||||
_wifi_reconnect_tracker.noteAttempt(now);
|
||||
Serial.printf("WebConfig: WiFi still unavailable; retrying '%s'\n",
|
||||
_wifi_ssid);
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.setAutoReconnect(true);
|
||||
WiFi.disconnect(false, false);
|
||||
WiFi.begin(_wifi_ssid, _wifi_password);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If boot occurred while the configured router was down, startAutoMode()
|
||||
// leaves a setup AP available instead of going dark. Keep that AP running,
|
||||
// but retry the saved station credentials on the same five-minute cadence.
|
||||
// A manually requested setup AP does not set this flag and is never closed
|
||||
// by a background reconnect.
|
||||
if (_mode == MODE_SETUP && _retry_saved_wifi_in_setup && _wifi_ssid[0]) {
|
||||
// An external owner (MQTT or the companion runtime) can restore the link
|
||||
// too. Promote as soon as any owner succeeds; only initiate retries here
|
||||
// when WebConfig itself owns WiFi.
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
if (_dns) {
|
||||
_dns->stop();
|
||||
delete _dns;
|
||||
_dns = NULL;
|
||||
}
|
||||
WiFi.softAPdisconnect(true);
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.setAutoReconnect(true);
|
||||
_was_setup_ap = false;
|
||||
_initial_setup = false;
|
||||
_retry_saved_wifi_in_setup = false;
|
||||
_setup_reconnect_in_progress = false;
|
||||
_setup_reconnect_deadline = 0;
|
||||
_wifi_reconnect_tracker.noteConnected();
|
||||
_mode = MODE_LAN;
|
||||
_last_activity = now;
|
||||
Serial.printf("WebConfig: saved WiFi recovered; ready at http://%s/\n",
|
||||
WiFi.localIP().toString().c_str());
|
||||
} else if (_owns_wifi && _setup_reconnect_in_progress
|
||||
&& (int32_t)(now - _setup_reconnect_deadline) >= 0) {
|
||||
WiFi.disconnect(false, false);
|
||||
_setup_reconnect_in_progress = false;
|
||||
_setup_reconnect_deadline = 0;
|
||||
Serial.println("WebConfig: saved WiFi still unavailable; setup AP remains active");
|
||||
} else if (_owns_wifi && !_setup_reconnect_in_progress
|
||||
&& _wifi_reconnect_tracker.retryDue(now)) {
|
||||
_wifi_reconnect_tracker.noteAttempt(now);
|
||||
_setup_reconnect_in_progress = true;
|
||||
_setup_reconnect_deadline = now + 20000UL;
|
||||
Serial.printf("WebConfig: retrying saved WiFi '%s'\n", _wifi_ssid);
|
||||
WiFi.begin(_wifi_ssid, _wifi_password);
|
||||
}
|
||||
}
|
||||
|
||||
if (_dns) _dns->processNextRequest();
|
||||
|
||||
if (_cli_state == CLI_PENDING) drainCliCommand();
|
||||
if (_batch_state == BATCH_PENDING) drainBatch(now);
|
||||
if (_setup_wifi_handoff_pending) serviceSetupWiFiHandoff(now);
|
||||
if (_batch_state == BATCH_PENDING && !_setup_wifi_handoff_pending) {
|
||||
drainBatch(now);
|
||||
}
|
||||
|
||||
if (WebConfigBatch::rebootDue(_reboot_at, now)) {
|
||||
Serial.printf("WC: rebooting now (%s)\n", _batch_reboot_armed ? "confirmed" : "fallback");
|
||||
@@ -681,7 +770,29 @@ void WebConfigServer::tick(uint32_t now) {
|
||||
// left broadcasting an open AP). LAN mode runs until `stop webconfig`.
|
||||
if (_mode == MODE_SETUP && WiFi.softAPgetStationNum() == 0 &&
|
||||
(now - _last_activity) > WEBCONFIG_AP_IDLE_TIMEOUT_MS) {
|
||||
requestStop();
|
||||
if (_retry_saved_wifi_in_setup && _wifi_ssid[0]) {
|
||||
// Close the open AP, but keep the WebConfig object alive in LAN mode so
|
||||
// its (or an external owner's) saved-network retries continue forever.
|
||||
// The listener becomes reachable again as soon as the router returns.
|
||||
if (_dns) {
|
||||
_dns->stop();
|
||||
delete _dns;
|
||||
_dns = NULL;
|
||||
}
|
||||
WiFi.softAPdisconnect(true);
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.setAutoReconnect(true);
|
||||
_was_setup_ap = false;
|
||||
_initial_setup = false;
|
||||
_retry_saved_wifi_in_setup = false;
|
||||
_setup_reconnect_in_progress = false;
|
||||
_setup_reconnect_deadline = 0;
|
||||
_mode = MODE_LAN;
|
||||
_last_activity = now;
|
||||
Serial.println("WebConfig: setup AP idle; saved WiFi recovery continues");
|
||||
} else {
|
||||
requestStop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,6 +816,67 @@ void WebConfigServer::drainCliCommand() {
|
||||
_cli_state = CLI_DONE;
|
||||
}
|
||||
|
||||
void WebConfigServer::finishBatch(uint32_t now) {
|
||||
WCLock lock(_mux);
|
||||
_batch_state = BATCH_DONE;
|
||||
const uint32_t reboot_at =
|
||||
WebConfigBatch::finishRebootAt(_batch_reboot, _batch_all_ok, now);
|
||||
if (reboot_at != 0) {
|
||||
// Fallback only: a successful result read replaces this with the shorter
|
||||
// confirmation delay. Failed batches remain available for correction.
|
||||
_reboot_at = reboot_at;
|
||||
}
|
||||
}
|
||||
|
||||
void WebConfigServer::serviceSetupWiFiHandoff(uint32_t now) {
|
||||
if (!_setup_wifi_handoff_pending) return;
|
||||
|
||||
IPAddress station_ip = WiFi.localIP();
|
||||
if (WiFi.status() == WL_CONNECTED && static_cast<uint32_t>(station_ip) != 0) {
|
||||
char ip[16];
|
||||
snprintf(ip, sizeof(ip), "%s", station_ip.toString().c_str());
|
||||
{
|
||||
WCLock lock(_mux);
|
||||
if (!_setup_wifi_handoff_pending) return;
|
||||
strncpy(_setup_wifi_handoff_ip, ip,
|
||||
sizeof(_setup_wifi_handoff_ip) - 1);
|
||||
_setup_wifi_handoff_ip[sizeof(_setup_wifi_handoff_ip) - 1] = 0;
|
||||
_setup_wifi_handoff_pending = false;
|
||||
_setup_wifi_handoff_deadline = 0;
|
||||
}
|
||||
Serial.printf("WebConfig: joined '%s' at %s; waiting for browser handoff\n",
|
||||
_wifi_ssid, ip);
|
||||
finishBatch(now);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!WebConfigBatch::deadlineReached(now, _setup_wifi_handoff_deadline)) {
|
||||
return;
|
||||
}
|
||||
|
||||
WiFi.disconnect(false, false); // stop STA attempt; leave the setup AP up
|
||||
{
|
||||
WCLock lock(_mux);
|
||||
if (!_setup_wifi_handoff_pending) return;
|
||||
for (int i = _batch_count - 1; i >= 0; i--) {
|
||||
if (strcmp(_batch[i].key, "wifi.ssid") == 0
|
||||
|| strcmp(_batch[i].key, "wifi.pwd") == 0) {
|
||||
snprintf(_batch[i].reply, sizeof(_batch[i].reply),
|
||||
"Error: could not join WiFi '%s'; setup AP remains active",
|
||||
_wifi_ssid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_batch_all_ok = false;
|
||||
_setup_wifi_handoff_pending = false;
|
||||
_setup_wifi_handoff_deadline = 0;
|
||||
_setup_wifi_handoff_ip[0] = 0;
|
||||
}
|
||||
Serial.printf("WebConfig: could not join '%s'; setup AP remains active\n",
|
||||
_wifi_ssid);
|
||||
finishBatch(now);
|
||||
}
|
||||
|
||||
void WebConfigServer::drainBatch(uint32_t now) {
|
||||
// One command per call, spaced out. Each `set` persists prefs with a flash
|
||||
// write, and flash writes stall the WiFi task (flash cache off); running a
|
||||
@@ -798,15 +970,35 @@ void WebConfigServer::drainBatch(uint32_t now) {
|
||||
WCLock prefs_lock(_mux);
|
||||
_cb->onConfigBatchEnd();
|
||||
}
|
||||
WCLock lock(_mux);
|
||||
_batch_state = BATCH_DONE;
|
||||
const uint32_t reboot_at =
|
||||
WebConfigBatch::finishRebootAt(_batch_reboot, _batch_all_ok, now);
|
||||
if (reboot_at != 0) {
|
||||
// Fallback only: a successful result read replaces this with the shorter
|
||||
// confirmation delay. Failed batches remain available for correction.
|
||||
_reboot_at = reboot_at;
|
||||
|
||||
bool wifi_credentials_changed = false;
|
||||
for (int i = 0; i < _batch_count; i++) {
|
||||
if (strcmp(_batch[i].key, "wifi.ssid") == 0
|
||||
|| strcmp(_batch[i].key, "wifi.pwd") == 0) {
|
||||
wifi_credentials_changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (WebConfigBatch::shouldStartSetupWiFiHandoff(
|
||||
_mode == MODE_SETUP, _batch_reboot, _batch_all_ok,
|
||||
wifi_credentials_changed, _wifi_ssid[0] != 0)) {
|
||||
{
|
||||
WCLock lock(_mux);
|
||||
_setup_wifi_handoff_pending = true;
|
||||
_setup_wifi_handoff_deadline =
|
||||
WebConfigBatch::setupWiFiConnectDeadline(now);
|
||||
_setup_wifi_handoff_ip[0] = 0;
|
||||
}
|
||||
// AP+STA keeps the captive page reachable while DHCP assigns the address
|
||||
// that will be shown to the operator before the setup AP is shut down.
|
||||
WiFi.mode(WIFI_AP_STA);
|
||||
WiFi.setAutoReconnect(false);
|
||||
Serial.printf("WebConfig: testing saved WiFi '%s' before reboot\n",
|
||||
_wifi_ssid);
|
||||
WiFi.begin(_wifi_ssid, _wifi_password);
|
||||
return;
|
||||
}
|
||||
finishBatch(now);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -932,7 +1124,13 @@ void WebConfigServer::collectBody(AsyncWebServerRequest* req, uint8_t* data, siz
|
||||
|
||||
bool WebConfigServer::checkAuth(AsyncWebServerRequest* req) {
|
||||
_last_activity = millis();
|
||||
if (_mode == MODE_SETUP) return true; // physical proximity implied, nothing configured
|
||||
if (_mode == MODE_SETUP) {
|
||||
// AP+STA is used for scans and saved-network recovery. Keep the unauthenticated
|
||||
// setup API reachable only through the physical setup AP, never through a
|
||||
// briefly recovered LAN interface before tick() promotes the mode.
|
||||
return req && req->client()
|
||||
&& req->client()->localIP() == WiFi.softAPIP();
|
||||
}
|
||||
if (_mode != MODE_LAN) return false;
|
||||
NodeSnapshot node = {};
|
||||
{
|
||||
@@ -1272,6 +1470,9 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
|
||||
strncpy(_batch_reqid, reqid, sizeof(_batch_reqid) - 1);
|
||||
_batch_reqid[sizeof(_batch_reqid) - 1] = 0;
|
||||
_standalone_wifi_dirty = false;
|
||||
_setup_wifi_handoff_pending = false;
|
||||
_setup_wifi_handoff_deadline = 0;
|
||||
_setup_wifi_handoff_ip[0] = 0;
|
||||
_batch_state = BATCH_PENDING; // tick() picks it up on the loop task
|
||||
uint32_t du = millis() + 60000;
|
||||
if (du == 0) du = 1;
|
||||
@@ -1339,6 +1540,9 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
|
||||
WebConfigBatch::doneReportsReboot(_batch_reboot, _batch_all_ok);
|
||||
doc["all_ok"] = _batch_all_ok;
|
||||
doc["reqid"] = (const char*)_batch_reqid;
|
||||
if (_setup_wifi_handoff_ip[0]) {
|
||||
doc["wifi_ip"] = (const char*)_setup_wifi_handoff_ip;
|
||||
}
|
||||
JsonArray results = doc.createNestedArray("results");
|
||||
for (int i = 0; i < _batch_count; i++) {
|
||||
JsonObject r = results.createNestedObject();
|
||||
@@ -1346,14 +1550,28 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
|
||||
r["reply"] = (const char*)_batch[i].reply;
|
||||
}
|
||||
// State stays DONE (re-readable) until the next POST claims the slot.
|
||||
const uint32_t result_now = millis();
|
||||
if (WebConfigBatch::shouldArmConfirmReboot(
|
||||
toSpecState(_batch_state), _batch_reboot,
|
||||
_batch_all_ok, _batch_reboot_armed)) {
|
||||
// Confirmation delivered - reboot 3 s from now (replaces the 30 s
|
||||
// drain-time fallback) so the UI can show its countdown first. Armed
|
||||
// once; re-reads must not keep pushing the deadline out.
|
||||
// Confirmation delivered. The handoff page remains rendered in the
|
||||
// browser after the AP disappears, so it only needs a one-second response
|
||||
// flush before reboot. Armed once; re-reads cannot push the deadline out.
|
||||
_batch_reboot_armed = true;
|
||||
_reboot_at = WebConfigBatch::confirmRebootAt(millis());
|
||||
const bool setup_handoff = _setup_wifi_handoff_ip[0] != 0;
|
||||
const uint32_t reboot_delay = setup_handoff
|
||||
? WebConfigBatch::kSetupHandoffRebootConfirmMs
|
||||
: WebConfigBatch::kRebootConfirmMs;
|
||||
doc["reboot_in_ms"] = reboot_delay;
|
||||
_reboot_at = setup_handoff
|
||||
? WebConfigBatch::confirmSetupHandoffRebootAt(result_now)
|
||||
: WebConfigBatch::confirmRebootAt(result_now);
|
||||
} else if (WebConfigBatch::doneReportsReboot(
|
||||
_batch_reboot, _batch_all_ok) && _reboot_at != 0) {
|
||||
// A retry after a lost HTTP response gets the same IP plus an accurate
|
||||
// remaining countdown; reading again never postpones the reboot.
|
||||
doc["reboot_in_ms"] = WebConfigBatch::deadlineReached(
|
||||
result_now, _reboot_at) ? 1u : _reboot_at - result_now;
|
||||
}
|
||||
|
||||
AsyncResponseStream* res = req->beginResponseStream("application/json");
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
// Two modes:
|
||||
// - SETUP: open SoftAP + captive portal, raised automatically on first boot
|
||||
// when no WiFi is configured (wifi_ssid empty), or manually via
|
||||
// `start webconfig ap`. Save -> reboot; auto-stops after an idle timeout.
|
||||
// `start webconfig ap`. A credential save joins in AP+STA mode, reports the
|
||||
// assigned LAN IP, then reboots; the AP auto-stops after an idle timeout.
|
||||
// - LAN: bound to an existing or portal-owned STA connection. Infrastructure
|
||||
// roles require the node admin password; companions use their trusted LAN.
|
||||
//
|
||||
@@ -27,6 +28,7 @@
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <helpers/WebConfigBatch.h>
|
||||
#include <helpers/WiFiReconnectPolicy.h>
|
||||
|
||||
class AsyncWebServer;
|
||||
class AsyncWebServerRequest;
|
||||
@@ -168,6 +170,9 @@ public:
|
||||
Mode mode() const { return _mode; }
|
||||
bool isRunning() const { return _mode != MODE_OFF; }
|
||||
bool isStopping() const { return _stopping; }
|
||||
bool isSavedWiFiRecoveryActive() const {
|
||||
return _mode == MODE_SETUP && _retry_saved_wifi_in_setup && !_stopping;
|
||||
}
|
||||
|
||||
private:
|
||||
static const int MAX_BATCH = WebConfigBatch::kMaxBatch;
|
||||
@@ -209,6 +214,10 @@ private:
|
||||
uint8_t _wifi_power_save = 1;
|
||||
bool _cli_enabled = true;
|
||||
char _ap_ssid[33] = {0};
|
||||
WiFiReconnectPolicy::Tracker _wifi_reconnect_tracker;
|
||||
bool _retry_saved_wifi_in_setup = false;
|
||||
bool _setup_reconnect_in_progress = false;
|
||||
uint32_t _setup_reconnect_deadline = 0;
|
||||
|
||||
// Currently attached session, also used by the display's setup-info poll.
|
||||
static WebConfigServer* _active;
|
||||
@@ -226,6 +235,9 @@ private:
|
||||
bool _batch_all_ok = true;
|
||||
char _batch_reqid[24] = {0};
|
||||
bool _standalone_wifi_dirty = false;
|
||||
bool _setup_wifi_handoff_pending = false;
|
||||
uint32_t _setup_wifi_handoff_deadline = 0;
|
||||
char _setup_wifi_handoff_ip[16] = {0};
|
||||
BatchEntry _batch[MAX_BATCH];
|
||||
|
||||
// Browser terminal command: filled by async_tcp under _mux and executed by
|
||||
@@ -268,6 +280,8 @@ private:
|
||||
void detachRoutes();
|
||||
uint32_t handlerRefCount() const;
|
||||
void drainBatch(uint32_t now);
|
||||
void serviceSetupWiFiHandoff(uint32_t now);
|
||||
void finishBatch(uint32_t now);
|
||||
void drainCliCommand();
|
||||
void finalizeTeardown();
|
||||
bool checkAuth(AsyncWebServerRequest* req);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <RadioLib.h>
|
||||
#include "MeshCore.h"
|
||||
#include "LR1110RxRecovery.h"
|
||||
|
||||
class CustomLR1110 : public LR1110 {
|
||||
uint32_t _preambleMillis = 66;
|
||||
@@ -13,17 +14,45 @@ class CustomLR1110 : public LR1110 {
|
||||
public:
|
||||
CustomLR1110(Module *mod) : LR1110(mod) { }
|
||||
|
||||
int16_t recoverReceivePath() {
|
||||
_activityAt = 0;
|
||||
_headerSeen = false;
|
||||
|
||||
// Standby is the operation known to reset the LR1110's unreported
|
||||
// four-byte RX-buffer displacement. Clear stale bytes and IRQ state too,
|
||||
// then let RadioLibWrapper re-arm receive mode.
|
||||
int16_t state = standby();
|
||||
int16_t next = clearRxBuffer();
|
||||
if (state == RADIOLIB_ERR_NONE) state = next;
|
||||
next = clearIrqState(RADIOLIB_LR11X0_IRQ_ALL);
|
||||
if (state == RADIOLIB_ERR_NONE) state = next;
|
||||
return state;
|
||||
}
|
||||
|
||||
size_t getPacketLength(bool update) override {
|
||||
size_t len = LR1110::getPacketLength(update);
|
||||
if (len == 0 && getIrqStatus() & RADIOLIB_LR11X0_IRQ_HEADER_ERR) {
|
||||
// we've just received a corrupted packet
|
||||
// this may have triggered a bug causing subsequent packets to be shifted
|
||||
// call standby() to return radio to known-good state
|
||||
// recvRaw will call startReceive() to restart rx
|
||||
MESH_DEBUG_PRINTLN("LR1110: got header err, calling standby()");
|
||||
standby();
|
||||
// GetRxBufferStatus can report either zero or a stale/nonzero length for
|
||||
// a header-error event. Recover based on the IRQ itself, never its length.
|
||||
if (getIrqStatus() & RADIOLIB_LR11X0_IRQ_HEADER_ERR) {
|
||||
MESH_DEBUG_PRINTLN("LR1110: header error, resetting RX path");
|
||||
recoverReceivePath();
|
||||
return 0;
|
||||
}
|
||||
return len;
|
||||
return LR1110::getPacketLength(update);
|
||||
}
|
||||
|
||||
int16_t readData(uint8_t* data, size_t len) override {
|
||||
int16_t state = LR1110::readData(data, len);
|
||||
if (state != RADIOLIB_ERR_NONE) return state;
|
||||
|
||||
// Defense in depth: if the trigger IRQ was missed, never pass the known
|
||||
// shifted/truncated representation up to Dispatcher where it can look
|
||||
// like a valid transport flood and be repeated by the mesh.
|
||||
if (mesh::hasLR1110RxBufferShiftSignature(data, len)) {
|
||||
MESH_DEBUG_PRINTLN("LR1110: four-byte RX shift detected, dropping packet");
|
||||
recoverReceivePath();
|
||||
return RADIOLIB_ERR_CRC_MISMATCH;
|
||||
}
|
||||
return RADIOLIB_ERR_NONE;
|
||||
}
|
||||
|
||||
float getFreqMHz() const { return freqMHz; }
|
||||
@@ -93,12 +122,12 @@ class CustomLR1110 : public LR1110 {
|
||||
bool getRxBoostedGainMode() const { return _rx_boosted; }
|
||||
|
||||
int16_t startReceive() override {
|
||||
// Make preamble detection visible to CAD while retaining RadioLib's
|
||||
// normal RX-complete and error events.
|
||||
// Keep preamble detection visible to CAD and route header errors to DIO1
|
||||
// so the wrapper can reset the RX path before another packet is read.
|
||||
return LR1110::startReceive(
|
||||
RADIOLIB_LR11X0_RX_TIMEOUT_INF,
|
||||
RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED),
|
||||
RADIOLIB_IRQ_RX_DEFAULT_MASK, 0);
|
||||
RADIOLIB_IRQ_RX_DEFAULT_MASK | (1UL << RADIOLIB_IRQ_HEADER_ERR), 0);
|
||||
}
|
||||
|
||||
// BUSY high means the chip is asleep (RX duty-cycle sleep window) or mid
|
||||
@@ -117,7 +146,9 @@ class CustomLR1110 : public LR1110 {
|
||||
bool hdrErr = irq & RADIOLIB_LR11X0_IRQ_HEADER_ERR; // bit 6
|
||||
uint32_t now = millis();
|
||||
if (hdrErr) {
|
||||
clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID | RADIOLIB_LR11X0_IRQ_HEADER_ERR);
|
||||
// Do not clear this here. The receive wrapper owns header-error
|
||||
// recovery and must see the IRQ in order to enter standby. Clearing it
|
||||
// from this polling path recreates the four-byte RX-buffer shift bug.
|
||||
_activityAt = 0;
|
||||
_headerSeen = false;
|
||||
return false;
|
||||
|
||||
@@ -58,6 +58,24 @@ public:
|
||||
bool supportsRxPowerSaving() const override { return true; }
|
||||
|
||||
protected:
|
||||
bool isPacketReady() override {
|
||||
// Header errors are recovery events, not packets. Handle them before the
|
||||
// generic duty-cycle readiness check can discard the IRQ or a stale buffer
|
||||
// length can be mistaken for a newly received frame.
|
||||
if (_radio->checkIrq(RADIOLIB_IRQ_HEADER_ERR) > 0) {
|
||||
MESH_DEBUG_PRINTLN("CustomLR1110Wrapper: recovering from header error");
|
||||
int16_t err = ((CustomLR1110 *)_radio)->recoverReceivePath();
|
||||
if (err != RADIOLIB_ERR_NONE) {
|
||||
MESH_DEBUG_PRINTLN("CustomLR1110Wrapper: RX recovery error (%d)", err);
|
||||
}
|
||||
_rx_ps_armed = false;
|
||||
_rx_hold_continuous = false;
|
||||
n_recv_errors++;
|
||||
return false;
|
||||
}
|
||||
return RadioLibWrapper::isPacketReady();
|
||||
}
|
||||
|
||||
int startReceiveMode() override {
|
||||
if (_rx_ps_armed) {
|
||||
// stop the previous duty-cycle sequence with an explicit standby before
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
// A damaged-header condition can make the LR1110 place every subsequent
|
||||
// packet four bytes beyond the offset reported by GetRxBufferStatus. Reading
|
||||
// from the reported offset then returns four zero bytes before the real frame.
|
||||
//
|
||||
// This helper is intentionally consumed only by CustomLR1110. On the MeshCore
|
||||
// wire, these bytes would otherwise decode as a transport-flood request whose
|
||||
// first transport code is the reserved value 0000.
|
||||
static constexpr size_t LR1110_RX_BUFFER_SHIFT_BYTES = 4;
|
||||
|
||||
inline bool hasLR1110RxBufferShiftSignature(const uint8_t* data, size_t len) {
|
||||
if (data == nullptr || len <= LR1110_RX_BUFFER_SHIFT_BYTES) return false;
|
||||
|
||||
for (size_t i = 0; i < LR1110_RX_BUFFER_SHIFT_BYTES; ++i) {
|
||||
if (data[i] != 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace mesh
|
||||
@@ -26,9 +26,12 @@ does not reflect the GoogleTest count -- run the built binary directly
|
||||
| `test_mqtt_presets` | `src/helpers/MQTTPresets.h` | preset lookup; table integrity (unique names, non-empty URLs, JWT-audience invariant, names fit the slot buffer); `mqttPresetNeedsSlotCredentials`; slot-count constants |
|
||||
| `test_observer_validation` | `src/helpers/MQTTObserverValidation.h` | IATA (exactly 3 alphanumerics), owner key (64 hex), NTP hostname, and the buffer-fit check behind the #17 length validation -- including boundaries and nulls |
|
||||
| `test_webconfig_keys` | `src/helpers/WebConfigKeys.h` | POST-key allowlist, secret detection, admin-password and browser-terminal command validation, slot-index bounds, and short-input guards |
|
||||
| `test_webconfig_batch` | `src/helpers/WebConfigBatch.h` | config POST/replay/busy decisions; paced command drain; setup WiFi validation/IP handoff; reboot confirmation/fallback; stop/refcount gating; exact timing and rollover boundaries |
|
||||
| `test_topic_template` | `src/helpers/MQTTTopicTemplate.h` | `{iata}/{device}/{token}/{type}` expansion, overflow/NUL-termination, and a buffer-size fuzz |
|
||||
| `test_mqtt_topic_router` | `src/helpers/MQTTTopicRouter.h` | complete preset/custom topic-routing contract; MeshRank packets-only behavior; required identifiers; invalid inputs/slots; exact buffer boundaries |
|
||||
| `test_mqtt_connection_policy` | `src/helpers/MQTTConnectionPolicy.h` | reconnect guard/backoff/stagger and breaker transitions; stable reset; JWT lifetime/renewal policy; exact timing boundaries and 32-bit `millis()` rollover |
|
||||
| `test_wifi_reconnect_policy` | `src/helpers/WiFiReconnectPolicy.h` | five-minute forced reconnect cadence, connection resets, duplicate disconnect observations, and 32-bit `millis()` rollover |
|
||||
| `test_lr1110_rx_recovery` | `src/helpers/radiolib/LR1110RxRecovery.h` | LR1110 four-byte RX-buffer shift signature; captured and accumulated shifts; ordinary/scoped packet exclusions |
|
||||
| `test_mqtt_packet_queue_policy` | `src/helpers/MQTTPacketQueuePolicy.h` | queue-full eviction; stale-disconnect flush; adaptive drain limits; bounded QoS0 retries; exact timing boundaries and 32-bit `millis()` rollover |
|
||||
| `test_mqtt_runtime_buffer_lifecycle` | `src/helpers/MQTTRuntimeBufferLifecycle.h` | idempotent allocation/release; partial-allocation degradation; retry of only missing buffers |
|
||||
| `test_mqtt_prefs_codec` | `src/helpers/MQTTPrefsStorage.h`, `src/helpers/MQTTPrefsCodec.h` | binary pre-slot/3-slot/6-slot migration fixtures; v1 header integrity; downgrade preservation |
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "helpers/radiolib/LR1110RxRecovery.h"
|
||||
|
||||
TEST(LR1110RxRecovery, RejectsNullAndShortBuffers) {
|
||||
EXPECT_FALSE(mesh::hasLR1110RxBufferShiftSignature(nullptr, 32));
|
||||
|
||||
const uint8_t empty = 0;
|
||||
EXPECT_FALSE(mesh::hasLR1110RxBufferShiftSignature(&empty, 0));
|
||||
|
||||
const uint8_t onlyPrefix[] = {0x00, 0x00, 0x00, 0x00};
|
||||
EXPECT_FALSE(mesh::hasLR1110RxBufferShiftSignature(
|
||||
onlyPrefix, sizeof(onlyPrefix)));
|
||||
}
|
||||
|
||||
TEST(LR1110RxRecovery, DetectsCapturedFourByteShift) {
|
||||
const uint8_t captured[] = {
|
||||
0x00, 0x00, 0x00, 0x00, 0x15, 0x04, 0x1c, 0x7f, 0x7a, 0x56};
|
||||
EXPECT_TRUE(mesh::hasLR1110RxBufferShiftSignature(
|
||||
captured, sizeof(captured)));
|
||||
|
||||
const uint8_t accumulatedShift[] = {
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x15, 0x04};
|
||||
EXPECT_TRUE(mesh::hasLR1110RxBufferShiftSignature(
|
||||
accumulatedShift, sizeof(accumulatedShift)));
|
||||
}
|
||||
|
||||
TEST(LR1110RxRecovery, AllowsOrdinaryAndValidScopedPrefixes) {
|
||||
const uint8_t ordinaryFlood[] = {0x15, 0x04, 0x1c, 0x7f, 0x7a, 0x56};
|
||||
EXPECT_FALSE(mesh::hasLR1110RxBufferShiftSignature(
|
||||
ordinaryFlood, sizeof(ordinaryFlood)));
|
||||
|
||||
// Header 00 is legal for a transport-flood request, but a real scope code
|
||||
// makes the prefix differ from the LR1110's four injected zero bytes.
|
||||
const uint8_t scopedRequest[] = {
|
||||
0x00, 0x34, 0x12, 0x78, 0x56, 0x01, 0xaa};
|
||||
EXPECT_FALSE(mesh::hasLR1110RxBufferShiftSignature(
|
||||
scopedRequest, sizeof(scopedRequest)));
|
||||
|
||||
const uint8_t threeZeros[] = {0x00, 0x00, 0x00, 0x15, 0x04, 0x1c};
|
||||
EXPECT_FALSE(mesh::hasLR1110RxBufferShiftSignature(
|
||||
threeZeros, sizeof(threeZeros)));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -109,6 +109,26 @@ TEST(WebConfigBatch, FinishArmsThirtySecondFallbackOnlyForAFullyOkRebootBatch) {
|
||||
EXPECT_EQ(0u, Batch::finishRebootAt(false, true, now));
|
||||
}
|
||||
|
||||
TEST(WebConfigBatch, SetupWiFiHandoffRequiresASuccessfulCredentialChangingReboot) {
|
||||
EXPECT_TRUE(Batch::shouldStartSetupWiFiHandoff(
|
||||
/*setup=*/true, /*reboot=*/true, /*all_ok=*/true,
|
||||
/*wifi_changed=*/true, /*has_ssid=*/true));
|
||||
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(false, true, true, true, true));
|
||||
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(true, false, true, true, true));
|
||||
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(true, true, false, true, true));
|
||||
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(true, true, true, false, true));
|
||||
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(true, true, true, true, false));
|
||||
}
|
||||
|
||||
TEST(WebConfigBatch, SetupWiFiHandoffUsesBoundedConnectAndBriefFlushDelays) {
|
||||
const uint32_t now = 500000;
|
||||
EXPECT_EQ(now + Batch::kSetupWiFiConnectTimeoutMs,
|
||||
Batch::setupWiFiConnectDeadline(now));
|
||||
EXPECT_EQ(now + Batch::kSetupHandoffRebootConfirmMs,
|
||||
Batch::confirmSetupHandoffRebootAt(now));
|
||||
EXPECT_EQ(1000U, Batch::kSetupHandoffRebootConfirmMs);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Result read
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <limits>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "helpers/WiFiReconnectPolicy.h"
|
||||
|
||||
namespace Policy = WiFiReconnectPolicy;
|
||||
|
||||
TEST(WiFiReconnectPolicy, RetriesAtFiveMinuteBoundaryAndEveryFiveMinutesAfter) {
|
||||
Policy::Tracker tracker;
|
||||
tracker.noteDisconnected(1000U);
|
||||
|
||||
EXPECT_FALSE(tracker.retryDue(1000U + Policy::kRetryIntervalMs - 1U));
|
||||
EXPECT_TRUE(tracker.retryDue(1000U + Policy::kRetryIntervalMs));
|
||||
|
||||
tracker.noteAttempt(1000U + Policy::kRetryIntervalMs);
|
||||
EXPECT_FALSE(tracker.retryDue(1000U + 2U * Policy::kRetryIntervalMs - 1U));
|
||||
EXPECT_TRUE(tracker.retryDue(1000U + 2U * Policy::kRetryIntervalMs));
|
||||
}
|
||||
|
||||
TEST(WiFiReconnectPolicy, ReconnectTimerResetsAfterAConnection) {
|
||||
Policy::Tracker tracker;
|
||||
tracker.noteDisconnected(1000U);
|
||||
tracker.noteConnected();
|
||||
|
||||
EXPECT_FALSE(tracker.isTracking());
|
||||
EXPECT_FALSE(tracker.retryDue(1000U + Policy::kRetryIntervalMs));
|
||||
|
||||
tracker.noteDisconnected(500000U);
|
||||
EXPECT_FALSE(tracker.retryDue(500000U + Policy::kRetryIntervalMs - 1U));
|
||||
EXPECT_TRUE(tracker.retryDue(500000U + Policy::kRetryIntervalMs));
|
||||
}
|
||||
|
||||
TEST(WiFiReconnectPolicy, DuplicateDisconnectObservationsDoNotPostponeRetry) {
|
||||
Policy::Tracker tracker;
|
||||
tracker.noteDisconnected(1000U);
|
||||
tracker.noteDisconnected(200000U);
|
||||
|
||||
EXPECT_TRUE(tracker.retryDue(1000U + Policy::kRetryIntervalMs));
|
||||
}
|
||||
|
||||
TEST(WiFiReconnectPolicy, TimersSurviveMillisRollover) {
|
||||
Policy::Tracker tracker;
|
||||
const uint32_t start = std::numeric_limits<uint32_t>::max() - 999U;
|
||||
tracker.noteDisconnected(start);
|
||||
|
||||
EXPECT_FALSE(tracker.retryDue(start + Policy::kRetryIntervalMs - 1U));
|
||||
EXPECT_TRUE(tracker.retryDue(start + Policy::kRetryIntervalMs));
|
||||
}
|
||||
|
||||
TEST(WiFiReconnectPolicy, DisconnectedDurationUsesExactWrapSafeBoundary) {
|
||||
Policy::Tracker tracker;
|
||||
const uint32_t start = std::numeric_limits<uint32_t>::max() - 99U;
|
||||
tracker.noteDisconnected(start);
|
||||
|
||||
EXPECT_FALSE(tracker.disconnectedFor(start + 119999U, 120000U));
|
||||
EXPECT_TRUE(tracker.disconnectedFor(start + 120000U, 120000U));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
+23
-9
@@ -228,7 +228,7 @@ canvas{width:100%;height:56px;display:block}
|
||||
<h2 id="wz-review-title">Step 4 · Review & save</h2>
|
||||
<div id="wz-review"></div>
|
||||
</div>
|
||||
<div class="note">Saving reboots the node into normal operation. To keep the page available later, run <b>set webui on</b>; use <b>get webui</b> to see its address.</div>
|
||||
<div class="note">Saving tests the selected WiFi, shows the node's new network address, then reboots into normal operation. To keep the page available later, run <b>set webui on</b>.</div>
|
||||
<div class="row">
|
||||
<button class="btn sec" id="wz-review-back" onclick="wzGo(3)">Back</button>
|
||||
<button class="btn" style="flex:1" id="wz-save" onclick="wizardSave()">Save & reboot</button>
|
||||
@@ -472,6 +472,7 @@ canvas{width:100%;height:56px;display:block}
|
||||
<span class="spin" style="width:34px;height:34px"></span>
|
||||
<h1 style="margin:16px 0 8px" id="reboot-title">Rebooting…</h1>
|
||||
<p id="reboot-msg" style="color:var(--mut);max-width:420px"></p>
|
||||
<p class="hide" id="reboot-ip" style="margin-top:14px">New network address:<br><a id="reboot-ip-link" href="#" style="font-size:20px;font-weight:650"></a></p>
|
||||
<p style="margin-top:14px;font-size:26px;font-weight:650" id="reboot-count"></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -816,7 +817,10 @@ function pollResult(cb,errs,idles,acked,reqid,submitted){
|
||||
if(fails)toast(fails+" setting(s) rejected - see fields");
|
||||
else if(r.reboot){
|
||||
st.dirty={};
|
||||
showReboot("The node is restarting with the saved WiFi settings. Reconnect to that network and open the node at its router-assigned address.",false,"Settings saved [OK] - rebooting");
|
||||
showReboot(r.wifi_ip
|
||||
? "The node joined the selected WiFi. This page and address stay visible while it reboots. Reconnect your phone or laptop to that network, then open the address."
|
||||
: "The node is restarting with the saved WiFi settings. Reconnect to that network and open the node at its router-assigned address.",
|
||||
false,"Settings saved [OK] - rebooting",r.wifi_ip,rebootSeconds(r));
|
||||
return;
|
||||
}else toast("Saved [OK]");
|
||||
loadConfigSoft();
|
||||
@@ -1264,9 +1268,9 @@ function wizardSave(){
|
||||
if(st.hasMqtt&&!/^[A-Za-z]{3}$/.test(st.dirty["mqtt.iata"]||st.orig["mqtt.iata"]||"")){
|
||||
$("#wz-err").textContent="Please enter a 3-letter IATA region code (MQTT step).";return}
|
||||
$("#wz-err").textContent="";
|
||||
var b=$("#wz-save");b.disabled=true;
|
||||
var b=$("#wz-save"),blabel=b.textContent;b.disabled=true;b.textContent="Testing WiFi...";
|
||||
saveChanges({reboot:true},function(r,acked){
|
||||
b.disabled=false;
|
||||
b.disabled=false;b.textContent=blabel;
|
||||
if(!r){
|
||||
if(acked){
|
||||
// The node accepted the batch (202), but without its result we cannot
|
||||
@@ -1284,25 +1288,35 @@ function wizardSave(){
|
||||
$("#wz-err").textContent=fails.map(function(x){return x.key+": "+x.reply}).join(" * ");
|
||||
return;
|
||||
}
|
||||
showReboot("The node is joining '"+eff("wifi.ssid")
|
||||
+"'. Reconnect your phone or laptop to your normal WiFi. Find the node's IP on your router, or run 'get webui' over its CLI to see the address.",
|
||||
false,"Settings saved [OK] - rebooting in a moment");
|
||||
if(r.wifi_ip){
|
||||
showReboot("The node joined '"+eff("wifi.ssid")
|
||||
+"'. This page and address stay visible while it reboots. Reconnect your phone or laptop to that WiFi, then open the address.",
|
||||
false,"WiFi connected [OK] - rebooting soon",r.wifi_ip,rebootSeconds(r));
|
||||
}else{
|
||||
showReboot("The settings were saved, but the node did not report a new network address. Reconnect to '"
|
||||
+eff("wifi.ssid")+"' and use your router or 'get webui' to find it.",
|
||||
false,"Settings saved [OK] - rebooting",null,rebootSeconds(r));
|
||||
}
|
||||
st.dirty={};
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- reboot ---------- */
|
||||
function rebootSeconds(r){return r&&r.reboot_in_ms?Math.max(1,Math.ceil(r.reboot_in_ms/1000)):30}
|
||||
function doReboot(){
|
||||
if(!confirm("Reboot this node now?"))return;
|
||||
post("/api/reboot",{}).then(function(){
|
||||
showReboot("The node is restarting. This page will try to reconnect automatically.",true);
|
||||
}).catch(function(e){if(e.message!=="auth")toast("Reboot failed: "+e.message)});
|
||||
}
|
||||
function showReboot(msg,reconnect,title){
|
||||
function showReboot(msg,reconnect,title,ip,seconds){
|
||||
$("#reboot-title").textContent=title||"Rebooting...";
|
||||
$("#reboot-msg").textContent=msg;
|
||||
var iprow=$("#reboot-ip"),iplink=$("#reboot-ip-link");
|
||||
if(ip){var url="http://"+ip+"/";iplink.textContent=url;iplink.href=url;iprow.classList.remove("hide")}
|
||||
else{iplink.textContent="";iplink.href="#";iprow.classList.add("hide")}
|
||||
$("#reboot-ov").classList.add("show");
|
||||
var n=30;$("#reboot-count").textContent=n;
|
||||
var n=seconds||30;$("#reboot-count").textContent=n;
|
||||
var t=setInterval(function(){
|
||||
n--;$("#reboot-count").textContent=n>0?n:"";
|
||||
if(n<=5&&reconnect){
|
||||
|
||||
Reference in new issue
Block a user