diff --git a/boards/t_beams3_supreme.json b/boards/t_beams3_supreme.json index 3eb9c016..f01b4f6e 100644 --- a/boards/t_beams3_supreme.json +++ b/boards/t_beams3_supreme.json @@ -7,6 +7,7 @@ }, "core": "esp32", "extra_flags": [ + "-DBOARD_HAS_PSRAM", "-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1", "-DARDUINO_RUNNING_CORE=1", @@ -15,6 +16,7 @@ "f_cpu": "240000000L", "f_flash": "80000000L", "flash_mode": "qio", + "psram_type": "qspi", "hwids": [ [ "0x303A", @@ -48,4 +50,4 @@ }, "url": "https://www.lilygo.cc/products/t-beamsupreme-m", "vendor": "LilyGo" - } \ No newline at end of file + } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index ca254a50..d93bcac9 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -185,6 +185,29 @@ static const char* getWifiStateLabel(wl_status_t status) { return "unknown"; } } + +static int getWifiQualityPercent(int rssi_dbm) { + if (rssi_dbm <= -100) { + return 0; + } + if (rssi_dbm >= -50) { + return 100; + } + return 2 * (rssi_dbm + 100); +} + +static const char* getWifiQualityLabel(int rssi_dbm) { + if (rssi_dbm >= -60) { + return "excellent"; + } + if (rssi_dbm >= -67) { + return "good"; + } + if (rssi_dbm >= -75) { + return "fair"; + } + return "poor"; +} #endif void MyMesh::writeOKFrame() { @@ -2049,8 +2072,11 @@ void MyMesh::checkCLIRescueCmd() { if (_prefs.wifi_ssid[0] == 0) { Serial.println(" > ssid:- status:off code:255 state:unconfigured"); } else if (status == WL_CONNECTED) { - Serial.printf(" > ssid:%s status:connected code:%d state:%s ip:%s\n", _prefs.wifi_ssid, - static_cast(status), getWifiStateLabel(status), WiFi.localIP().toString().c_str()); + const int rssi_dbm = WiFi.RSSI(); + Serial.printf(" > ssid:%s status:connected code:%d state:%s ip:%s rssi:%d quality:%d%% signal:%s\n", + _prefs.wifi_ssid, static_cast(status), getWifiStateLabel(status), + WiFi.localIP().toString().c_str(), rssi_dbm, getWifiQualityPercent(rssi_dbm), + getWifiQualityLabel(rssi_dbm)); } else { const char* overall = (status == WL_IDLE_STATUS) ? "connecting" : "disconnected"; Serial.printf(" > ssid:%s status:%s code:%d state:%s\n", _prefs.wifi_ssid, overall, diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index e75920de..9c8b4b5b 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1081,16 +1081,16 @@ void MyMesh::removeNeighbor(const uint8_t *pubkey, int key_len) { #endif } -void MyMesh::formatStatsReply(char *reply) { - StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); +void MyMesh::formatStatsReply(char *reply, size_t reply_size) { + StatsFormatHelper::formatCoreStats(reply, reply_size, board, *_ms, _err_flags, _mgr); } -void MyMesh::formatRadioStatsReply(char *reply) { - StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); +void MyMesh::formatRadioStatsReply(char *reply, size_t reply_size) { + StatsFormatHelper::formatRadioStats(reply, reply_size, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); } -void MyMesh::formatPacketStatsReply(char *reply) { - StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), +void MyMesh::formatPacketStatsReply(char *reply, size_t reply_size) { + StatsFormatHelper::formatPacketStats(reply, reply_size, radio_driver, getNumSentFlood(), getNumSentDirect(), getNumRecvFlood(), getNumRecvDirect()); } @@ -1439,6 +1439,9 @@ void MyMesh::runWebCommand(const char* command, char* reply, size_t reply_size) matches_exact("start ota") || matches_exact("get wifi.status") || matches_exact("get wifi.powersaving") || + matches_exact("stats-core") || + matches_exact("stats-radio") || + matches_exact("stats-packets") || matches_exact("memory") || matches_exact("get mqtt.iata") || matches_exact("get mqtt.owner") || diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index a1131934..3e88af25 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -212,9 +212,9 @@ public: void setTxPower(int8_t power_dbm) override; void formatNeighborsReply(char *reply) override; void removeNeighbor(const uint8_t* pubkey, int key_len) override; - void formatStatsReply(char *reply) override; - void formatRadioStatsReply(char *reply) override; - void formatPacketStatsReply(char *reply) override; + void formatStatsReply(char *reply, size_t reply_size) override; + void formatRadioStatsReply(char *reply, size_t reply_size) override; + void formatPacketStatsReply(char *reply, size_t reply_size) override; void formatMemoryReply(char *reply, size_t reply_size) override; mesh::LocalIdentity& getSelfId() override { return self_id; } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 04ce49c9..3bf2f58c 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -750,16 +750,16 @@ void MyMesh::clearStats() { ((SimpleMeshTables *)getTables())->resetStats(); } -void MyMesh::formatStatsReply(char *reply) { - StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); +void MyMesh::formatStatsReply(char *reply, size_t reply_size) { + StatsFormatHelper::formatCoreStats(reply, reply_size, board, *_ms, _err_flags, _mgr); } -void MyMesh::formatRadioStatsReply(char *reply) { - StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); +void MyMesh::formatRadioStatsReply(char *reply, size_t reply_size) { + StatsFormatHelper::formatRadioStats(reply, reply_size, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); } -void MyMesh::formatPacketStatsReply(char *reply) { - StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), +void MyMesh::formatPacketStatsReply(char *reply, size_t reply_size) { + StatsFormatHelper::formatPacketStats(reply, reply_size, radio_driver, getNumSentFlood(), getNumSentDirect(), getNumRecvFlood(), getNumRecvDirect()); } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 5e85cce5..58f7c8a3 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -193,9 +193,9 @@ public: void formatNeighborsReply(char *reply) override { strcpy(reply, "not supported"); } - void formatStatsReply(char *reply) override; - void formatRadioStatsReply(char *reply) override; - void formatPacketStatsReply(char *reply) override; + void formatStatsReply(char *reply, size_t reply_size) override; + void formatRadioStatsReply(char *reply, size_t reply_size) override; + void formatPacketStatsReply(char *reply, size_t reply_size) override; void formatMemoryReply(char *reply, size_t reply_size) override; mesh::LocalIdentity& getSelfId() override { return self_id; } diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index aea52dd1..387c5d58 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -820,16 +820,16 @@ void SensorMesh::setTxPower(int8_t power_dbm) { radio_set_tx_power(power_dbm); } -void SensorMesh::formatStatsReply(char *reply) { - StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); +void SensorMesh::formatStatsReply(char *reply, size_t reply_size) { + StatsFormatHelper::formatCoreStats(reply, reply_size, board, *_ms, _err_flags, _mgr); } -void SensorMesh::formatRadioStatsReply(char *reply) { - StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); +void SensorMesh::formatRadioStatsReply(char *reply, size_t reply_size) { + StatsFormatHelper::formatRadioStats(reply, reply_size, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); } -void SensorMesh::formatPacketStatsReply(char *reply) { - StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), +void SensorMesh::formatPacketStatsReply(char *reply, size_t reply_size) { + StatsFormatHelper::formatPacketStats(reply, reply_size, radio_driver, getNumSentFlood(), getNumSentDirect(), getNumRecvFlood(), getNumRecvDirect()); } diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 4a0812a3..1761b9db 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -70,9 +70,9 @@ public: void formatNeighborsReply(char *reply) override { strcpy(reply, "not supported"); } - void formatStatsReply(char *reply) override; - void formatRadioStatsReply(char *reply) override; - void formatPacketStatsReply(char *reply) override; + void formatStatsReply(char *reply, size_t reply_size) override; + void formatRadioStatsReply(char *reply, size_t reply_size) override; + void formatPacketStatsReply(char *reply, size_t reply_size) override; void formatMemoryReply(char *reply, size_t reply_size) override; mesh::LocalIdentity& getSelfId() override { return self_id; } void saveIdentity(const mesh::LocalIdentity& new_id) override; diff --git a/platformio.ini b/platformio.ini index fcad6303..def24659 100644 --- a/platformio.ini +++ b/platformio.ini @@ -48,6 +48,7 @@ build_flags = -w -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1 build_src_filter = +<*.cpp> + + + + + + diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 9d7a1113..0e46ad96 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -88,6 +88,9 @@ void Dispatcher::loop() { total_air_time += t; //Serial.print(" airtime="); Serial.println(t); + // A completed transmit means the radio recovered from any prior CAD stall. + _err_flags &= ~ERR_EVENT_CAD_TIMEOUT; + updateTxBudget(); if (t > tx_budget_ms) { @@ -386,4 +389,4 @@ unsigned long Dispatcher::futureMillis(int millis_from_now) const { return _ms->getMillis() + millis_from_now; } -} \ No newline at end of file +} diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 94eb75eb..d66fc899 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -876,13 +876,13 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else if (sender_timestamp == 0 && memcmp(command, "log", 3) == 0) { _callbacks->dumpLogFile(); strcpy(reply, " EOF"); - } else if (sender_timestamp == 0 && memcmp(command, "stats-packets", 13) == 0 && (command[13] == 0 || command[13] == ' ')) { - _callbacks->formatPacketStatsReply(reply); - } else if (sender_timestamp == 0 && memcmp(command, "stats-radio", 11) == 0 && (command[11] == 0 || command[11] == ' ')) { - _callbacks->formatRadioStatsReply(reply); - } else if (sender_timestamp == 0 && memcmp(command, "stats-core", 10) == 0 && (command[10] == 0 || command[10] == ' ')) { - _callbacks->formatStatsReply(reply); - } else if (sender_timestamp == 0 && memcmp(command, "memory", 6) == 0 && (command[6] == 0 || command[6] == ' ')) { + } else if (memcmp(command, "stats-packets", 13) == 0 && (command[13] == 0 || command[13] == ' ')) { + _callbacks->formatPacketStatsReply(reply, 160); + } else if (memcmp(command, "stats-radio", 11) == 0 && (command[11] == 0 || command[11] == ' ')) { + _callbacks->formatRadioStatsReply(reply, 160); + } else if (memcmp(command, "stats-core", 10) == 0 && (command[10] == 0 || command[10] == ' ')) { + _callbacks->formatStatsReply(reply, 160); + } else if (memcmp(command, "memory", 6) == 0 && (command[6] == 0 || command[6] == ' ')) { _callbacks->formatMemoryReply(reply, 160); } else { strcpy(reply, "Unknown command"); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 5044610e..a2ce0a7f 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -80,9 +80,9 @@ public: virtual void removeNeighbor(const uint8_t* pubkey, int key_len) { // no op by default }; - virtual void formatStatsReply(char *reply) = 0; - virtual void formatRadioStatsReply(char *reply) = 0; - virtual void formatPacketStatsReply(char *reply) = 0; + virtual void formatStatsReply(char *reply, size_t reply_size) = 0; + virtual void formatRadioStatsReply(char *reply, size_t reply_size) = 0; + virtual void formatPacketStatsReply(char *reply, size_t reply_size) = 0; virtual void formatMemoryReply(char *reply, size_t reply_size) = 0; virtual mesh::LocalIdentity& getSelfId() = 0; virtual void saveIdentity(const mesh::LocalIdentity& new_id) = 0; diff --git a/src/helpers/StatsFormatHelper.h b/src/helpers/StatsFormatHelper.h index 4b5e5921..a5cbb72f 100644 --- a/src/helpers/StatsFormatHelper.h +++ b/src/helpers/StatsFormatHelper.h @@ -5,12 +5,13 @@ class StatsFormatHelper { public: - static void formatCoreStats(char* reply, - mesh::MainBoard& board, - mesh::MillisecondClock& ms, + static void formatCoreStats(char* reply, + size_t reply_size, + mesh::MainBoard& board, + mesh::MillisecondClock& ms, uint16_t err_flags, mesh::PacketManager* mgr) { - sprintf(reply, + snprintf(reply, reply_size, "{\"battery_mv\":%u,\"uptime_secs\":%u,\"errors\":%u,\"queue_len\":%u}", board.getBattMilliVolts(), ms.getMillis() / 1000, @@ -21,11 +22,12 @@ public: template static void formatRadioStats(char* reply, + size_t reply_size, mesh::Radio* radio, RadioDriverType& driver, uint32_t total_air_time_ms, uint32_t total_rx_air_time_ms) { - sprintf(reply, + snprintf(reply, reply_size, "{\"noise_floor\":%d,\"last_rssi\":%d,\"last_snr\":%.2f,\"tx_air_secs\":%u,\"rx_air_secs\":%u}", (int16_t)radio->getNoiseFloor(), (int16_t)driver.getLastRSSI(), @@ -37,12 +39,13 @@ public: template static void formatPacketStats(char* reply, + size_t reply_size, RadioDriverType& driver, uint32_t n_sent_flood, uint32_t n_sent_direct, uint32_t n_recv_flood, uint32_t n_recv_direct) { - sprintf(reply, + snprintf(reply, reply_size, "{\"recv\":%u,\"sent\":%u,\"flood_tx\":%u,\"direct_tx\":%u,\"flood_rx\":%u,\"direct_rx\":%u,\"recv_errors\":%u}", driver.getPacketsRecv(), driver.getPacketsSent(), diff --git a/src/helpers/mqtt/MQTTPrefs.cpp b/src/helpers/mqtt/MQTTPrefs.cpp index 0104166d..a9b1173f 100644 --- a/src/helpers/mqtt/MQTTPrefs.cpp +++ b/src/helpers/mqtt/MQTTPrefs.cpp @@ -65,7 +65,9 @@ bool MQTTPrefsStore::save(FILESYSTEM* fs, const MQTTPrefs& prefs) { if (fs == nullptr) { return false; } - fs->remove(kFilename); + if (fs->exists(kFilename) && !fs->remove(kFilename)) { + return false; + } #if defined(RP2040_PLATFORM) File file = fs->open(kFilename, "w"); #else diff --git a/src/helpers/mqtt/MQTTUplink.cpp b/src/helpers/mqtt/MQTTUplink.cpp index 09ab1d5b..80abf71d 100644 --- a/src/helpers/mqtt/MQTTUplink.cpp +++ b/src/helpers/mqtt/MQTTUplink.cpp @@ -1,8 +1,5 @@ #include "MQTTUplink.h" #include "MQTTCaCerts.h" -#if defined(WITH_WEB_PANEL) && WITH_WEB_PANEL - #include "generated/WebPanelCert.h" -#endif #ifdef WITH_MQTT_UPLINK @@ -51,17 +48,35 @@ #endif namespace { -constexpr unsigned long kWifiStartupDelayMillis = 750; constexpr unsigned long kWifiRetryMillis = 15000; constexpr unsigned long kWifiConnectTimeoutMillis = 45000; constexpr unsigned long kBrokerRetryMillis = 10000; constexpr time_t kTokenLifetimeSecs = 3600; constexpr time_t kTokenRefreshSlackSecs = 300; constexpr time_t kMinSaneEpoch = 1735689600; // 2025-01-01T00:00:00Z -constexpr size_t kWebServerStackSize = 8192; -constexpr size_t kWebPasswordBufferSize = 80; -constexpr size_t kWebCommandBufferSize = 192; -constexpr size_t kWebReplyBufferSize = 256; + +int getWifiQualityPercent(int rssi_dbm) { + if (rssi_dbm <= -100) { + return 0; + } + if (rssi_dbm >= -50) { + return 100; + } + return 2 * (rssi_dbm + 100); +} + +const char* getWifiQualityLabel(int rssi_dbm) { + if (rssi_dbm >= -60) { + return "excellent"; + } + if (rssi_dbm >= -67) { + return "good"; + } + if (rssi_dbm >= -75) { + return "fair"; + } + return "poor"; +} char* allocScratchBuffer(size_t size) { void* ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); @@ -77,352 +92,6 @@ void freeScratchBuffer(void* ptr) { } } -#if WITH_WEB_PANEL -const char kWebPanelHtml[] PROGMEM = R"HTML( - - - - - - Repeater Config - - - -
- -
-

Repeater Config

-

Use the repeater admin password to unlock the command console. Accept the self-signed certificate warning in your browser first.

-
- - -
-
-
- -
-

Run CLI Command

-
- - -
-

Only the allowlisted commands exposed by this panel will run here.

-
-
- - - - - - -
- - - -)HTML"; -#endif - const char* getWifiStateLabel(const MQTTPrefs& prefs, bool wifi_started) { if (prefs.wifi_ssid[0] == 0) { return "off"; @@ -449,18 +118,11 @@ const MQTTUplink::BrokerSpec MQTTUplink::kBrokerSpecs[3] = { MQTTUplink::MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity) : _fs(nullptr), _rtc(&rtc), _identity(&identity), _running(false), _wifi_started(false), _sntp_started(false), - _have_time_sync(false), _wifi_sta_started_at(0), _last_wifi_attempt(0), _last_status_publish(0), _last_status{}, + _have_time_sync(false), _last_wifi_attempt(0), _last_status_publish(0), _last_status{}, _node_name(nullptr), _web_runner(nullptr) -#if WITH_WEB_PANEL - , _web_server(nullptr) -#endif { memset(_device_id, 0, sizeof(_device_id)); -#if WITH_WEB_PANEL - memset(_web_token, 0, sizeof(_web_token)); - _web_route_context.self = this; -#endif MQTTPrefsStore::setDefaults(_prefs); for (size_t i = 0; i < 3; ++i) { memset(&_brokers[i], 0, sizeof(_brokers[i])); @@ -469,6 +131,11 @@ MQTTUplink::MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity) MQTT_LOG("uplink init"); } +void MQTTUplink::setWebCommandRunner(MQTTWebCommandRunner* runner) { + _web_runner = runner; + _web_panel.setCommandRunner(runner); +} + bool MQTTUplink::savePrefs() { return MQTTPrefsStore::save(_fs, _prefs); } @@ -494,7 +161,6 @@ void MQTTUplink::reconnectWifi() { _wifi_started = false; _sntp_started = false; _have_time_sync = false; - _wifi_sta_started_at = 0; _last_wifi_attempt = 0; } @@ -847,165 +513,8 @@ void MQTTUplink::handleMqttEvent(void* handler_args, esp_event_base_t, int32_t e } } -#if WITH_WEB_PANEL -esp_err_t MQTTUplink::handleWebIndex(httpd_req_t* req) { - auto* ctx = static_cast(req->user_ctx); - if (ctx == nullptr || ctx->self == nullptr) { - return httpd_resp_send_500(req); - } - httpd_resp_set_type(req, "text/html; charset=utf-8"); - httpd_resp_set_hdr(req, "Cache-Control", "no-store"); - return httpd_resp_send(req, kWebPanelHtml, HTTPD_RESP_USE_STRLEN); -} -#endif - -#if WITH_WEB_PANEL -esp_err_t MQTTUplink::handleWebLogin(httpd_req_t* req) { - auto* ctx = static_cast(req->user_ctx); - if (ctx == nullptr || ctx->self == nullptr || ctx->self->_web_runner == nullptr) { - return httpd_resp_send_500(req); - } - - char* password = allocScratchBuffer(kWebPasswordBufferSize); - if (password == nullptr) { - return httpd_resp_send_500(req); - } - - if (!ctx->self->readRequestBody(req, password, kWebPasswordBufferSize)) { - freeScratchBuffer(password); - return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad request"); - } - - if (strcmp(password, ctx->self->_web_runner->getWebAdminPassword()) != 0) { - freeScratchBuffer(password); - WEB_LOG("login denied"); - return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Bad password"); - } - - freeScratchBuffer(password); - ctx->self->refreshWebToken(); - WEB_LOG("login accepted"); - httpd_resp_set_type(req, "text/plain; charset=utf-8"); - httpd_resp_set_hdr(req, "Cache-Control", "no-store"); - return httpd_resp_sendstr(req, ctx->self->_web_token); -} -#endif - -#if WITH_WEB_PANEL -esp_err_t MQTTUplink::handleWebCommand(httpd_req_t* req) { - auto* ctx = static_cast(req->user_ctx); - if (ctx == nullptr || ctx->self == nullptr || ctx->self->_web_runner == nullptr) { - return httpd_resp_send_500(req); - } - if (!ctx->self->isWebAuthorized(req)) { - return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); - } - - char* command = allocScratchBuffer(kWebCommandBufferSize); - char* reply = allocScratchBuffer(kWebReplyBufferSize); - if (command == nullptr || reply == nullptr) { - freeScratchBuffer(command); - freeScratchBuffer(reply); - return httpd_resp_send_500(req); - } - - if (!ctx->self->readRequestBody(req, command, kWebCommandBufferSize)) { - freeScratchBuffer(command); - freeScratchBuffer(reply); - return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad request"); - } - - memset(reply, 0, kWebReplyBufferSize); - ctx->self->_web_runner->runWebCommand(command, reply, kWebReplyBufferSize); - httpd_resp_set_type(req, "text/plain; charset=utf-8"); - httpd_resp_set_hdr(req, "Cache-Control", "no-store"); - esp_err_t rc = httpd_resp_send(req, reply[0] ? reply : "OK", HTTPD_RESP_USE_STRLEN); - freeScratchBuffer(command); - freeScratchBuffer(reply); - return rc; -} -#endif - -#if WITH_WEB_PANEL -bool MQTTUplink::readRequestBody(httpd_req_t* req, char* buffer, size_t buffer_size) const { - if (req == nullptr || buffer == nullptr || buffer_size == 0 || req->content_len <= 0 || - req->content_len >= static_cast(buffer_size)) { - return false; - } - - int remaining = req->content_len; - int offset = 0; - while (remaining > 0) { - int read = httpd_req_recv(req, &buffer[offset], remaining); - if (read <= 0) { - return false; - } - offset += read; - remaining -= read; - } - buffer[offset] = 0; - return true; -} - -void MQTTUplink::refreshWebToken() { - uint8_t token[16]; - esp_fill_random(token, sizeof(token)); - bytesToHexUpper(token, sizeof(token), _web_token, sizeof(_web_token)); -} - -bool MQTTUplink::isWebAuthorized(httpd_req_t* req) const { - if (_web_token[0] == 0) { - return false; - } - char token[40]; - if (httpd_req_get_hdr_value_str(req, "X-Auth-Token", token, sizeof(token)) != ESP_OK) { - return false; - } - return strcmp(token, _web_token) == 0; -} - -bool MQTTUplink::startWebServer() { - if (_web_server != nullptr || _web_runner == nullptr || _prefs.web_enabled == 0) { - return _web_server != nullptr; - } - - httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT(); - config.httpd.max_open_sockets = 2; - config.httpd.max_uri_handlers = 3; - config.httpd.max_resp_headers = 4; - config.httpd.backlog_conn = 2; - config.httpd.recv_wait_timeout = 2; - config.httpd.send_wait_timeout = 2; - config.httpd.stack_size = kWebServerStackSize; - config.cacert_pem = reinterpret_cast(mqtt_web_panel_cert::kServerCertPem); - config.cacert_len = sizeof(mqtt_web_panel_cert::kServerCertPem); - config.prvtkey_pem = reinterpret_cast(mqtt_web_panel_cert::kServerKeyPem); - config.prvtkey_len = sizeof(mqtt_web_panel_cert::kServerKeyPem); - - esp_err_t rc = httpd_ssl_start(&_web_server, &config); - if (rc != ESP_OK) { - _web_server = nullptr; - WEB_LOG("server start failed rc=0x%x", static_cast(rc)); - return false; - } - - httpd_uri_t index_uri = {.uri = "/", .method = HTTP_GET, .handler = &MQTTUplink::handleWebIndex, .user_ctx = &_web_route_context}; - httpd_uri_t login_uri = {.uri = "/login", .method = HTTP_POST, .handler = &MQTTUplink::handleWebLogin, .user_ctx = &_web_route_context}; - httpd_uri_t command_uri = {.uri = "/api/command", .method = HTTP_POST, .handler = &MQTTUplink::handleWebCommand, .user_ctx = &_web_route_context}; - httpd_register_uri_handler(_web_server, &index_uri); - httpd_register_uri_handler(_web_server, &login_uri); - httpd_register_uri_handler(_web_server, &command_uri); - WEB_LOG("server started on https://%s/", WiFi.localIP().toString().c_str()); - return true; -} - void MQTTUplink::stopWebServer() { - if (_web_server != nullptr) { - WEB_LOG("server stopped"); - httpd_ssl_stop(_web_server); - _web_server = nullptr; - } - _web_token[0] = 0; + _web_panel.stop(); } void MQTTUplink::ensureWebServer() { @@ -1013,12 +522,8 @@ void MQTTUplink::ensureWebServer() { stopWebServer(); return; } - startWebServer(); + _web_panel.start(); } -#else -void MQTTUplink::ensureWebServer() { } -void MQTTUplink::stopWebServer() { } -#endif void MQTTUplink::ensureWifi() { if (_prefs.wifi_ssid[0] == 0) { @@ -1037,7 +542,6 @@ void MQTTUplink::ensureWifi() { _wifi_started = false; _sntp_started = false; _have_time_sync = false; - _wifi_sta_started_at = 0; } return; } @@ -1049,12 +553,7 @@ void MQTTUplink::ensureWifi() { unsigned long now_ms = millis(); wl_status_t status = WiFi.status(); if (_wifi_started) { - if (_last_wifi_attempt == 0) { - if (now_ms - _wifi_sta_started_at < kWifiStartupDelayMillis) { - return; - } - } else if (status == WL_IDLE_STATUS && now_ms - _last_wifi_attempt < kWifiConnectTimeoutMillis) { - return; + if (_last_wifi_attempt != 0 && status == WL_IDLE_STATUS && now_ms - _last_wifi_attempt < kWifiConnectTimeoutMillis) { return; } if (now_ms - _last_wifi_attempt < kWifiRetryMillis) { @@ -1066,11 +565,7 @@ void MQTTUplink::ensureWifi() { WiFi.mode(WIFI_STA); WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave)); _wifi_started = true; - _wifi_sta_started_at = now_ms; - _last_wifi_attempt = 0; - WIFI_LOG("sta start powersaving=%s settle_ms=%lu", getPowerSaveLabel(_prefs.wifi_powersave), - static_cast(kWifiStartupDelayMillis)); - return; + WIFI_LOG("sta start powersaving=%s", getPowerSaveLabel(_prefs.wifi_powersave)); } else { WIFI_LOG("retry status=%d", static_cast(status)); } @@ -1199,9 +694,6 @@ void MQTTUplink::begin(FILESYSTEM* fs) { _fs = fs; MQTTPrefsStore::load(_fs, _prefs); refreshIdentityStrings(); -#if WITH_WEB_PANEL - refreshWebToken(); -#endif _running = true; _last_status_publish = millis(); MQTT_LOG("begin iata=%s enabled_mask=0x%02X wifi_ssid=%s", _prefs.iata, _prefs.enabled_mask, _prefs.wifi_ssid); @@ -1221,7 +713,6 @@ void MQTTUplink::end() { _wifi_started = false; _sntp_started = false; _have_time_sync = false; - _wifi_sta_started_at = 0; _last_wifi_attempt = 0; _running = false; } @@ -1335,13 +826,13 @@ void MQTTUplink::formatWebStatusReply(char* reply, size_t reply_size) const { return; } - if (_web_server == nullptr || !_wifi_started || WiFi.status() != WL_CONNECTED) { + if (!_web_panel.isRunning() || !_wifi_started || WiFi.status() != WL_CONNECTED) { snprintf(reply, reply_size, "> web:down"); return; } snprintf(reply, reply_size, "> web:up url:https://%s/ auth:%s", WiFi.localIP().toString().c_str(), - _web_token[0] ? "unlocked" : "locked"); + _web_panel.hasSessionToken() ? "unlocked" : "locked"); #else (void)reply_size; snprintf(reply, reply_size, "> web:unsupported"); @@ -1542,8 +1033,11 @@ void MQTTUplink::formatWifiStatusReply(char* reply, size_t reply_size) const { } if (wifi_status == WL_CONNECTED) { - snprintf(reply, reply_size, "> ssid:%s status:%s code:%d state:%s ip:%s", _prefs.wifi_ssid, status, - static_cast(wifi_status), state, WiFi.localIP().toString().c_str()); + const int rssi_dbm = WiFi.RSSI(); + snprintf(reply, reply_size, + "> ssid:%s status:%s code:%d state:%s ip:%s rssi:%d quality:%d%% signal:%s", + _prefs.wifi_ssid, status, static_cast(wifi_status), state, WiFi.localIP().toString().c_str(), + rssi_dbm, getWifiQualityPercent(rssi_dbm), getWifiQualityLabel(rssi_dbm)); } else { snprintf(reply, reply_size, "> ssid:%s status:%s code:%d state:%s", _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : "-", status, static_cast(wifi_status), state); @@ -1554,7 +1048,7 @@ void MQTTUplink::formatWifiStatusReply(char* reply, size_t reply_size) const { MQTTUplink::MQTTUplink(mesh::RTCClock&, mesh::LocalIdentity&) : _fs(nullptr), _rtc(nullptr), _identity(nullptr), _running(false), _wifi_started(false), _sntp_started(false), - _have_time_sync(false), _wifi_sta_started_at(0), _last_wifi_attempt(0), _last_status_publish(0), _last_status{}, + _have_time_sync(false), _last_wifi_attempt(0), _last_status_publish(0), _last_status{}, _node_name(nullptr), _web_runner(nullptr) { } diff --git a/src/helpers/mqtt/MQTTUplink.h b/src/helpers/mqtt/MQTTUplink.h index 2492d433..3b4191ea 100644 --- a/src/helpers/mqtt/MQTTUplink.h +++ b/src/helpers/mqtt/MQTTUplink.h @@ -4,6 +4,7 @@ #include #include +#include #include #include "JWTHelper.h" @@ -14,18 +15,10 @@ #if !defined(WITH_WEB_PANEL) #define WITH_WEB_PANEL 1 #endif -#if WITH_WEB_PANEL - #include -#endif #include #endif -class MQTTWebCommandRunner { -public: - virtual ~MQTTWebCommandRunner() = default; - virtual void runWebCommand(const char* command, char* reply, size_t reply_size) = 0; - virtual const char* getWebAdminPassword() const = 0; -}; +using MQTTWebCommandRunner = WebPanelCommandRunner; struct MQTTStatusSnapshot { int battery_mv; @@ -72,7 +65,7 @@ public: bool setIata(const char* iata); const char* getIata() const { return _prefs.iata; } void setNodeNameSource(const char* node_name) { _node_name = node_name; } - void setWebCommandRunner(MQTTWebCommandRunner* runner) { _web_runner = runner; } + void setWebCommandRunner(MQTTWebCommandRunner* runner); bool setWifiSSID(const char* ssid); bool setWifiPassword(const char* pwd); const char* getWifiSSID() const { return _prefs.wifi_ssid; } @@ -109,12 +102,6 @@ private: char raw_topic[128]; char offline_payload[256]; }; - -#if WITH_WEB_PANEL - struct WebRouteContext { - MQTTUplink* self; - }; -#endif #endif FILESYSTEM* _fs; @@ -125,13 +112,13 @@ private: bool _wifi_started; bool _sntp_started; bool _have_time_sync; - unsigned long _wifi_sta_started_at; unsigned long _last_wifi_attempt; unsigned long _last_status_publish; MQTTStatusSnapshot _last_status; char _device_id[65]; const char* _node_name; MQTTWebCommandRunner* _web_runner; + WebPanelServer _web_panel; #if defined(ESP_PLATFORM) static constexpr uint8_t kEastmeshBit = 0x01; @@ -140,26 +127,10 @@ private: static const BrokerSpec kBrokerSpecs[3]; BrokerState _brokers[3]; -#if WITH_WEB_PANEL - httpd_handle_t _web_server; - char _web_token[33]; - WebRouteContext _web_route_context; -#endif static void handleMqttEvent(void* handler_args, esp_event_base_t base, int32_t event_id, void* event_data); -#if WITH_WEB_PANEL - static esp_err_t handleWebIndex(httpd_req_t* req); - static esp_err_t handleWebLogin(httpd_req_t* req); - static esp_err_t handleWebCommand(httpd_req_t* req); -#endif void ensureWebServer(); void stopWebServer(); - bool startWebServer(); -#if WITH_WEB_PANEL - bool isWebAuthorized(httpd_req_t* req) const; - bool readRequestBody(httpd_req_t* req, char* buffer, size_t buffer_size) const; - void refreshWebToken(); -#endif void ensureWifi(); void updateTimeSync(); bool hasEnabledBroker() const; diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp new file mode 100644 index 00000000..79275c73 --- /dev/null +++ b/src/helpers/web/WebPanelServer.cpp @@ -0,0 +1,1294 @@ +#include "WebPanelServer.h" + +#if defined(ESP_PLATFORM) && WITH_WEB_PANEL + +#include +#include +#include +#include +#include + +#include "../mqtt/generated/WebPanelCert.h" + +namespace { + +constexpr size_t kWebServerStackSize = 8192; +constexpr size_t kWebPasswordBufferSize = 80; +constexpr size_t kWebCommandBufferSize = 192; +constexpr size_t kWebReplyBufferSize = 256; +constexpr size_t kWebJsonBufferSize = 2048; + +#if defined(MQTT_DEBUG) && MQTT_DEBUG + #define WEB_PANEL_LOG(fmt, ...) Serial.printf("[WEB] " fmt "\n", ##__VA_ARGS__) +#else + #define WEB_PANEL_LOG(...) do { } while (0) +#endif + +char* allocScratchBuffer(size_t size) { + void* ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (ptr == nullptr) { + ptr = heap_caps_malloc(size, MALLOC_CAP_8BIT); + } + return static_cast(ptr); +} + +void freeScratchBuffer(void* ptr) { + if (ptr != nullptr) { + heap_caps_free(ptr); + } +} + +void bytesToHexUpper(const uint8_t* src, size_t len, char* dst, size_t dst_size) { + if (dst_size == 0) { + return; + } + size_t di = 0; + for (size_t i = 0; i < len && di + 2 < dst_size; ++i) { + snprintf(&dst[di], dst_size - di, "%02X", src[i]); + di += 2; + } + dst[(di < dst_size) ? di : (dst_size - 1)] = 0; +} + +size_t appendJsonEscaped(char* dst, size_t dst_size, size_t offset, const char* src) { + if (dst == nullptr || dst_size == 0) { + return offset; + } + for (size_t i = 0; src != nullptr && src[i] != 0 && offset + 2 < dst_size; ++i) { + char c = src[i]; + if (c == '\\' || c == '"') { + if (offset + 2 >= dst_size) break; + dst[offset++] = '\\'; + dst[offset++] = c; + } else if (c == '\n') { + if (offset + 2 >= dst_size) break; + dst[offset++] = '\\'; + dst[offset++] = 'n'; + } else if (c == '\r') { + if (offset + 2 >= dst_size) break; + dst[offset++] = '\\'; + dst[offset++] = 'r'; + } else if (c == '\t') { + if (offset + 2 >= dst_size) break; + dst[offset++] = '\\'; + dst[offset++] = 't'; + } else { + dst[offset++] = c; + } + } + dst[(offset < dst_size) ? offset : (dst_size - 1)] = 0; + return offset; +} + +bool appendJsonField(char* dst, size_t dst_size, size_t& offset, const char* key, const char* value, bool comma) { + int written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s\"%s\":\"", comma ? "," : "", key); + if (written < 0 || offset + static_cast(written) >= dst_size) { + return false; + } + offset += static_cast(written); + offset = appendJsonEscaped(dst, dst_size, offset, value != nullptr ? value : ""); + if (offset + 2 >= dst_size) { + return false; + } + dst[offset++] = '"'; + dst[offset] = 0; + return true; +} + +bool appendJsonFieldRaw(char* dst, size_t dst_size, size_t& offset, const char* key, const char* value, bool comma) { + int written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s\"%s\":", comma ? "," : "", key); + if (written < 0 || offset + static_cast(written) >= dst_size) { + return false; + } + offset += static_cast(written); + if (value == nullptr) { + value = "null"; + } + written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s", value); + if (written < 0 || offset + static_cast(written) >= dst_size) { + return false; + } + offset += static_cast(written); + return true; +} + +const char kWebPanelHtml[] PROGMEM = R"HTML( + + + + + + Repeater Config + + + +
+
+

Repeater Config

+

Use the repeater admin password to unlock the command console. Accept the self-signed certificate warning in your browser first.

+
+ + +
+
+
+ + + + + +
+

Run CLI Command

+
+ + +
+

Only the allowlisted commands exposed by this panel will run here.

+
+
+ + + + + + + +
+ + + +)HTML"; + +} // namespace + +WebPanelServer::WebPanelServer() + : _runner(nullptr), _server(nullptr), _token{0}, _route_context{this} { +} + +void WebPanelServer::setCommandRunner(WebPanelCommandRunner* runner) { + _runner = runner; +} + +bool WebPanelServer::start() { + if (_server != nullptr || _runner == nullptr) { + return _server != nullptr; + } + + if (_token[0] == 0) { + refreshToken(); + } + + httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT(); + config.httpd.max_open_sockets = 2; + config.httpd.max_uri_handlers = 5; + config.httpd.max_resp_headers = 4; + config.httpd.backlog_conn = 2; + config.httpd.recv_wait_timeout = 2; + config.httpd.send_wait_timeout = 2; + config.httpd.stack_size = kWebServerStackSize; +#if defined(ESP_IDF_VERSION_MAJOR) && ESP_IDF_VERSION_MAJOR >= 5 + config.servercert = reinterpret_cast(mqtt_web_panel_cert::kServerCertPem); + config.servercert_len = sizeof(mqtt_web_panel_cert::kServerCertPem); +#else + // IDF 4.x uses the misnamed CA slot for the server certificate. + config.cacert_pem = reinterpret_cast(mqtt_web_panel_cert::kServerCertPem); + config.cacert_len = sizeof(mqtt_web_panel_cert::kServerCertPem); +#endif + config.prvtkey_pem = reinterpret_cast(mqtt_web_panel_cert::kServerKeyPem); + config.prvtkey_len = sizeof(mqtt_web_panel_cert::kServerKeyPem); + + esp_err_t rc = httpd_ssl_start(&_server, &config); + if (rc != ESP_OK) { + _server = nullptr; + WEB_PANEL_LOG("server start failed rc=0x%x", static_cast(rc)); + return false; + } + + httpd_uri_t index_uri = {.uri = "/", .method = HTTP_GET, .handler = &WebPanelServer::handleIndex, .user_ctx = &_route_context}; + httpd_uri_t login_uri = {.uri = "/login", .method = HTTP_POST, .handler = &WebPanelServer::handleLogin, .user_ctx = &_route_context}; + httpd_uri_t command_uri = {.uri = "/api/command", .method = HTTP_POST, .handler = &WebPanelServer::handleCommand, .user_ctx = &_route_context}; + httpd_uri_t bootstrap_uri = {.uri = "/api/bootstrap", .method = HTTP_GET, .handler = &WebPanelServer::handleBootstrap, .user_ctx = &_route_context}; + httpd_uri_t stats_uri = {.uri = "/api/stats", .method = HTTP_GET, .handler = &WebPanelServer::handleStats, .user_ctx = &_route_context}; + httpd_register_uri_handler(_server, &index_uri); + httpd_register_uri_handler(_server, &login_uri); + httpd_register_uri_handler(_server, &command_uri); + httpd_register_uri_handler(_server, &bootstrap_uri); + httpd_register_uri_handler(_server, &stats_uri); + WEB_PANEL_LOG("server started on https://%s/", WiFi.localIP().toString().c_str()); + return true; +} + +void WebPanelServer::stop() { + if (_server != nullptr) { + WEB_PANEL_LOG("server stopped"); + httpd_ssl_stop(_server); + _server = nullptr; + } + _token[0] = 0; +} + +bool WebPanelServer::isRunning() const { + return _server != nullptr; +} + +bool WebPanelServer::hasSessionToken() const { + return _token[0] != 0; +} + +esp_err_t WebPanelServer::handleIndex(httpd_req_t* req) { + auto* ctx = static_cast(req->user_ctx); + if (ctx == nullptr || ctx->self == nullptr) { + return httpd_resp_send_500(req); + } + httpd_resp_set_type(req, "text/html; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + return httpd_resp_send(req, kWebPanelHtml, HTTPD_RESP_USE_STRLEN); +} + +esp_err_t WebPanelServer::handleLogin(httpd_req_t* req) { + auto* ctx = static_cast(req->user_ctx); + if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) { + return httpd_resp_send_500(req); + } + + char* password = allocScratchBuffer(kWebPasswordBufferSize); + if (password == nullptr) { + return httpd_resp_send_500(req); + } + + if (!ctx->self->readRequestBody(req, password, kWebPasswordBufferSize)) { + freeScratchBuffer(password); + return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad request"); + } + + if (strcmp(password, ctx->self->_runner->getWebAdminPassword()) != 0) { + freeScratchBuffer(password); + WEB_PANEL_LOG("login denied"); + return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Bad password"); + } + + freeScratchBuffer(password); + ctx->self->refreshToken(); + WEB_PANEL_LOG("login accepted"); + httpd_resp_set_type(req, "text/plain; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + return httpd_resp_sendstr(req, ctx->self->_token); +} + +esp_err_t WebPanelServer::handleCommand(httpd_req_t* req) { + auto* ctx = static_cast(req->user_ctx); + if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) { + return httpd_resp_send_500(req); + } + if (!ctx->self->isAuthorized(req)) { + return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); + } + + char* command = allocScratchBuffer(kWebCommandBufferSize); + char* reply = allocScratchBuffer(kWebReplyBufferSize); + if (command == nullptr || reply == nullptr) { + freeScratchBuffer(command); + freeScratchBuffer(reply); + return httpd_resp_send_500(req); + } + + if (!ctx->self->readRequestBody(req, command, kWebCommandBufferSize)) { + freeScratchBuffer(command); + freeScratchBuffer(reply); + return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad request"); + } + + memset(reply, 0, kWebReplyBufferSize); + ctx->self->_runner->runWebCommand(command, reply, kWebReplyBufferSize); + httpd_resp_set_type(req, "text/plain; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + esp_err_t rc = httpd_resp_send(req, reply[0] ? reply : "OK", HTTPD_RESP_USE_STRLEN); + freeScratchBuffer(command); + freeScratchBuffer(reply); + return rc; +} + +esp_err_t WebPanelServer::handleBootstrap(httpd_req_t* req) { + auto* ctx = static_cast(req->user_ctx); + if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) { + return httpd_resp_send_500(req); + } + if (!ctx->self->isAuthorized(req)) { + return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); + } + + char* reply = allocScratchBuffer(kWebReplyBufferSize); + char* json = allocScratchBuffer(kWebJsonBufferSize); + if (reply == nullptr || json == nullptr) { + freeScratchBuffer(reply); + freeScratchBuffer(json); + return httpd_resp_send_500(req); + } + + const struct { + const char* key; + const char* command; + } fields[] = { + {"name", "get name"}, + {"mqtt_iata", "get mqtt.iata"}, + {"mqtt_owner", "get mqtt.owner"}, + {"mqtt_email", "get mqtt.email"}, + {"advert_interval", "get advert.interval"}, + {"flood_interval", "get flood.advert.interval"}, + {"flood_max", "get flood.max"}, + }; + + size_t offset = 0; + json[offset++] = '{'; + json[offset] = 0; + for (size_t i = 0; i < (sizeof(fields) / sizeof(fields[0])); ++i) { + memset(reply, 0, kWebReplyBufferSize); + ctx->self->_runner->runWebCommand(fields[i].command, reply, kWebReplyBufferSize); + const char* value = reply; + if (value[0] == '>' && value[1] == ' ') { + value += 2; + } + if (strcmp(value, "-") == 0) { + value = ""; + } + if (!appendJsonField(json, kWebJsonBufferSize, offset, fields[i].key, value, i != 0)) { + freeScratchBuffer(reply); + freeScratchBuffer(json); + return httpd_resp_send_500(req); + } + } + if (offset + 2 >= kWebJsonBufferSize) { + freeScratchBuffer(reply); + freeScratchBuffer(json); + return httpd_resp_send_500(req); + } + json[offset++] = '}'; + json[offset] = 0; + + httpd_resp_set_type(req, "application/json; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + esp_err_t rc = httpd_resp_send(req, json, HTTPD_RESP_USE_STRLEN); + freeScratchBuffer(reply); + freeScratchBuffer(json); + return rc; +} + +esp_err_t WebPanelServer::handleStats(httpd_req_t* req) { + auto* ctx = static_cast(req->user_ctx); + if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) { + return httpd_resp_send_500(req); + } + if (!ctx->self->isAuthorized(req)) { + return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); + } + + char* reply = allocScratchBuffer(kWebReplyBufferSize); + char* json = allocScratchBuffer(kWebJsonBufferSize); + if (reply == nullptr || json == nullptr) { + freeScratchBuffer(reply); + freeScratchBuffer(json); + return httpd_resp_send_500(req); + } + + const struct { + const char* key; + const char* command; + } fields[] = { + {"wifi", "get wifi.status"}, + {"wifi_powersave", "get wifi.powersaving"}, + {"core", "stats-core"}, + {"radio", "stats-radio"}, + {"packets", "stats-packets"}, + {"memory", "memory"}, + }; + + size_t offset = 0; + json[offset++] = '{'; + json[offset] = 0; + for (size_t i = 0; i < (sizeof(fields) / sizeof(fields[0])); ++i) { + memset(reply, 0, kWebReplyBufferSize); + ctx->self->_runner->runWebCommand(fields[i].command, reply, kWebReplyBufferSize); + if (!appendJsonField(json, kWebJsonBufferSize, offset, fields[i].key, reply, i != 0)) { + freeScratchBuffer(reply); + freeScratchBuffer(json); + return httpd_resp_send_500(req); + } + } + if (offset + 2 >= kWebJsonBufferSize) { + freeScratchBuffer(reply); + freeScratchBuffer(json); + return httpd_resp_send_500(req); + } + json[offset++] = '}'; + json[offset] = 0; + + httpd_resp_set_type(req, "application/json; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + esp_err_t rc = httpd_resp_send(req, json, HTTPD_RESP_USE_STRLEN); + freeScratchBuffer(reply); + freeScratchBuffer(json); + return rc; +} + +bool WebPanelServer::readRequestBody(httpd_req_t* req, char* buffer, size_t buffer_size) const { + if (req == nullptr || buffer == nullptr || buffer_size == 0 || req->content_len <= 0 || + req->content_len >= static_cast(buffer_size)) { + return false; + } + + int remaining = req->content_len; + int offset = 0; + while (remaining > 0) { + int read = httpd_req_recv(req, &buffer[offset], remaining); + if (read <= 0) { + return false; + } + offset += read; + remaining -= read; + } + buffer[offset] = 0; + return true; +} + +void WebPanelServer::refreshToken() { + uint8_t token[16]; + esp_fill_random(token, sizeof(token)); + bytesToHexUpper(token, sizeof(token), _token, sizeof(_token)); +} + +bool WebPanelServer::isAuthorized(httpd_req_t* req) const { + if (_token[0] == 0) { + return false; + } + char token[40]; + if (httpd_req_get_hdr_value_str(req, "X-Auth-Token", token, sizeof(token)) != ESP_OK) { + return false; + } + return strcmp(token, _token) == 0; +} + +#else + +WebPanelServer::WebPanelServer() + : _runner(nullptr) { +} + +void WebPanelServer::setCommandRunner(WebPanelCommandRunner* runner) { + _runner = runner; +} + +bool WebPanelServer::start() { + return false; +} + +void WebPanelServer::stop() { +} + +bool WebPanelServer::isRunning() const { + return false; +} + +bool WebPanelServer::hasSessionToken() const { + return false; +} + +#endif diff --git a/src/helpers/web/WebPanelServer.h b/src/helpers/web/WebPanelServer.h new file mode 100644 index 00000000..2d6febe5 --- /dev/null +++ b/src/helpers/web/WebPanelServer.h @@ -0,0 +1,55 @@ +#pragma once + +#include + +#if defined(ESP_PLATFORM) + #include + #if !defined(WITH_WEB_PANEL) + #define WITH_WEB_PANEL 1 + #endif + #if WITH_WEB_PANEL + #include + #endif +#endif + +class WebPanelCommandRunner { +public: + virtual ~WebPanelCommandRunner() = default; + virtual void runWebCommand(const char* command, char* reply, size_t reply_size) = 0; + virtual const char* getWebAdminPassword() const = 0; +}; + +class WebPanelServer { +public: + WebPanelServer(); + + void setCommandRunner(WebPanelCommandRunner* runner); + bool start(); + void stop(); + bool isRunning() const; + bool hasSessionToken() const; + +private: +#if defined(ESP_PLATFORM) && WITH_WEB_PANEL + struct RouteContext { + WebPanelServer* self; + }; + + WebPanelCommandRunner* _runner; + httpd_handle_t _server; + char _token[33]; + RouteContext _route_context; + + static esp_err_t handleIndex(httpd_req_t* req); + static esp_err_t handleLogin(httpd_req_t* req); + static esp_err_t handleCommand(httpd_req_t* req); + static esp_err_t handleBootstrap(httpd_req_t* req); + static esp_err_t handleStats(httpd_req_t* req); + + bool readRequestBody(httpd_req_t* req, char* buffer, size_t buffer_size) const; + void refreshToken(); + bool isAuthorized(httpd_req_t* req) const; +#else + WebPanelCommandRunner* _runner; +#endif +};