Webserver addition and TactilityC symbols (#451)
This commit is contained in:
@@ -60,6 +60,9 @@ namespace service {
|
||||
#if TT_FEATURE_SCREENSHOT_ENABLED
|
||||
namespace screenshot { extern const ServiceManifest manifest; }
|
||||
#endif
|
||||
#ifdef ESP_PLATFORM
|
||||
namespace webserver { extern const ServiceManifest manifest; }
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
@@ -104,17 +107,17 @@ namespace app {
|
||||
namespace wifiapsettings { extern const AppManifest manifest; }
|
||||
namespace wificonnect { extern const AppManifest manifest; }
|
||||
namespace wifimanage { extern const AppManifest manifest; }
|
||||
#ifdef ESP_PLATFORM
|
||||
namespace webserversettings { extern const AppManifest manifest; }
|
||||
#endif
|
||||
#if TT_FEATURE_SCREENSHOT_ENABLED
|
||||
namespace screenshot { extern const AppManifest manifest; }
|
||||
namespace screenshot { extern const AppManifest manifest; }
|
||||
#endif
|
||||
#ifdef ESP_PLATFORM
|
||||
namespace crashdiagnostics { extern const AppManifest manifest; }
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifndef ESP_PLATFORM
|
||||
#endif
|
||||
|
||||
// endregion
|
||||
|
||||
// List of all apps excluding Boot app (as Boot app calls this function indirectly)
|
||||
@@ -146,6 +149,9 @@ static void registerInternalApps() {
|
||||
addAppManifest(app::wifiapsettings::manifest);
|
||||
addAppManifest(app::wificonnect::manifest);
|
||||
addAppManifest(app::wifimanage::manifest);
|
||||
#ifdef ESP_PLATFORM
|
||||
addAppManifest(app::webserversettings::manifest);
|
||||
#endif
|
||||
|
||||
#if defined(CONFIG_TINYUSB_MSC_ENABLED) && CONFIG_TINYUSB_MSC_ENABLED
|
||||
addAppManifest(app::usbsettings::manifest);
|
||||
@@ -263,6 +269,9 @@ static void registerAndStartPrimaryServices() {
|
||||
#if defined(CONFIG_TT_WIFI_ENABLED) && !defined(CONFIG_ESP_WIFI_REMOTE_ENABLED)
|
||||
addService(service::espnow::manifest);
|
||||
#endif
|
||||
#ifdef ESP_PLATFORM
|
||||
addService(service::webserver::manifest);
|
||||
#endif
|
||||
}
|
||||
|
||||
void createTempDirectory(const std::string& rootPath) {
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
#ifdef ESP_PLATFORM
|
||||
|
||||
#include <Tactility/Tactility.h>
|
||||
#include <Tactility/settings/WebServerSettings.h>
|
||||
#include <Tactility/service/wifi/Wifi.h>
|
||||
#include <Tactility/service/wifi/WifiApSettings.h>
|
||||
#include <Tactility/service/webserver/AssetVersion.h>
|
||||
#include <Tactility/service/webserver/WebServerService.h>
|
||||
#include <Tactility/Assets.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <Tactility/Logger.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
|
||||
#include <esp_netif.h>
|
||||
#include <esp_wifi.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/timers.h>
|
||||
|
||||
namespace tt::app::webserversettings {
|
||||
|
||||
static const auto LOGGER = tt::Logger("WebServerSettingsApp");
|
||||
|
||||
class WebServerSettingsApp final : public App {
|
||||
|
||||
settings::webserver::WebServerSettings wsSettings;
|
||||
settings::webserver::WebServerSettings originalSettings;
|
||||
bool updated = false;
|
||||
bool wifiSettingsChanged = false;
|
||||
bool webServerEnabledChanged = false;
|
||||
lv_obj_t* dropdownWifiMode = nullptr;
|
||||
lv_obj_t* textAreaApPassword = nullptr;
|
||||
lv_obj_t* switchApOpenNetwork = nullptr;
|
||||
lv_obj_t* switchWebServerEnabled = nullptr;
|
||||
lv_obj_t* switchWebServerAuthEnabled = nullptr;
|
||||
lv_obj_t* textAreaWebServerUsername = nullptr;
|
||||
lv_obj_t* textAreaWebServerPassword = nullptr;
|
||||
lv_obj_t* labelUrl = nullptr;
|
||||
lv_obj_t* labelUrlValue = nullptr;
|
||||
|
||||
static void onWifiModeChanged(lv_event_t* e) {
|
||||
auto* app = static_cast<WebServerSettingsApp*>(lv_event_get_user_data(e));
|
||||
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(e));
|
||||
auto index = lv_dropdown_get_selected(dropdown);
|
||||
getMainDispatcher().dispatch([app, index] {
|
||||
app->wsSettings.wifiMode = static_cast<settings::webserver::WiFiMode>(index);
|
||||
app->updated = true;
|
||||
app->wifiSettingsChanged = true;
|
||||
app->updateUrlDisplay();
|
||||
});
|
||||
}
|
||||
|
||||
static void onWebServerEnabledSwitch(lv_event_t* e) {
|
||||
auto* app = static_cast<WebServerSettingsApp*>(lv_event_get_user_data(e));
|
||||
bool enabled = lv_obj_has_state(app->switchWebServerEnabled, LV_STATE_CHECKED);
|
||||
getMainDispatcher().dispatch([app, enabled] {
|
||||
app->wsSettings.webServerEnabled = enabled;
|
||||
app->updated = true;
|
||||
app->webServerEnabledChanged = true;
|
||||
app->updateUrlDisplay();
|
||||
});
|
||||
}
|
||||
|
||||
static void onWebServerAuthEnabledSwitch(lv_event_t* e) {
|
||||
auto* app = static_cast<WebServerSettingsApp*>(lv_event_get_user_data(e));
|
||||
bool enabled = lv_obj_has_state(app->switchWebServerAuthEnabled, LV_STATE_CHECKED);
|
||||
|
||||
if (app->textAreaWebServerUsername && app->textAreaWebServerPassword) {
|
||||
if (enabled) {
|
||||
lv_obj_remove_state(app->textAreaWebServerUsername, LV_STATE_DISABLED);
|
||||
lv_obj_add_flag(app->textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
lv_obj_remove_state(app->textAreaWebServerPassword, LV_STATE_DISABLED);
|
||||
lv_obj_add_flag(app->textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE);
|
||||
} else {
|
||||
lv_obj_add_state(app->textAreaWebServerUsername, LV_STATE_DISABLED);
|
||||
lv_obj_remove_flag(app->textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
lv_obj_add_state(app->textAreaWebServerPassword, LV_STATE_DISABLED);
|
||||
lv_obj_remove_flag(app->textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE);
|
||||
}
|
||||
}
|
||||
|
||||
getMainDispatcher().dispatch([app, enabled] {
|
||||
app->wsSettings.webServerAuthEnabled = enabled;
|
||||
app->updated = true;
|
||||
});
|
||||
}
|
||||
|
||||
static void onCredentialChanged(lv_event_t* e) {
|
||||
auto* app = static_cast<WebServerSettingsApp*>(lv_event_get_user_data(e));
|
||||
getMainDispatcher().dispatch([app] {
|
||||
app->updated = true;
|
||||
});
|
||||
}
|
||||
|
||||
static void onApPasswordChanged(lv_event_t* e) {
|
||||
auto* app = static_cast<WebServerSettingsApp*>(lv_event_get_user_data(e));
|
||||
getMainDispatcher().dispatch([app] {
|
||||
app->updated = true;
|
||||
app->wifiSettingsChanged = true;
|
||||
});
|
||||
}
|
||||
|
||||
static void onApOpenNetworkSwitch(lv_event_t* e) {
|
||||
auto* app = static_cast<WebServerSettingsApp*>(lv_event_get_user_data(e));
|
||||
bool openNetwork = lv_obj_has_state(app->switchApOpenNetwork, LV_STATE_CHECKED);
|
||||
|
||||
if (app->textAreaApPassword) {
|
||||
if (openNetwork) {
|
||||
lv_obj_add_state(app->textAreaApPassword, LV_STATE_DISABLED);
|
||||
lv_obj_remove_flag(app->textAreaApPassword, LV_OBJ_FLAG_CLICKABLE);
|
||||
} else {
|
||||
lv_obj_remove_state(app->textAreaApPassword, LV_STATE_DISABLED);
|
||||
lv_obj_add_flag(app->textAreaApPassword, LV_OBJ_FLAG_CLICKABLE);
|
||||
}
|
||||
}
|
||||
|
||||
getMainDispatcher().dispatch([app, openNetwork] {
|
||||
app->wsSettings.apOpenNetwork = openNetwork;
|
||||
app->updated = true;
|
||||
app->wifiSettingsChanged = true;
|
||||
});
|
||||
}
|
||||
|
||||
static void onSyncAssets(lv_event_t* e) {
|
||||
auto* app = static_cast<WebServerSettingsApp*>(lv_event_get_user_data(e));
|
||||
auto* btn = static_cast<lv_obj_t*>(lv_event_get_target_obj(e));
|
||||
lv_obj_add_state(btn, LV_STATE_DISABLED);
|
||||
LOGGER.info("Manual asset sync triggered");
|
||||
|
||||
getMainDispatcher().dispatch([app, btn]{
|
||||
bool success = service::webserver::syncAssets();
|
||||
if (success) {
|
||||
LOGGER.info("Asset sync completed successfully");
|
||||
} else {
|
||||
LOGGER.error("Asset sync failed");
|
||||
}
|
||||
// Only re-enable if button still exists (user hasn't navigated away)
|
||||
if (lv_obj_is_valid(btn)) {
|
||||
lv_obj_remove_state(btn, LV_STATE_DISABLED);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void updateUrlDisplay() {
|
||||
if (!labelUrlValue) return;
|
||||
|
||||
if (!wsSettings.webServerEnabled) {
|
||||
lv_label_set_text(labelUrlValue, "Disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
std::string url = "http://";
|
||||
|
||||
if (wsSettings.wifiMode == settings::webserver::WiFiMode::AccessPoint) {
|
||||
// AP mode - always 192.168.4.1
|
||||
url += "192.168.4.1";
|
||||
} else {
|
||||
// Station mode - try to get actual IP
|
||||
esp_netif_t* netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
|
||||
if (netif != nullptr) {
|
||||
esp_netif_ip_info_t ip_info;
|
||||
if (esp_netif_get_ip_info(netif, &ip_info) == ESP_OK && ip_info.ip.addr != 0) {
|
||||
char ip_str[16];
|
||||
snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip));
|
||||
url += ip_str;
|
||||
} else {
|
||||
url = "Connecting...";
|
||||
}
|
||||
} else {
|
||||
url = "Not connected";
|
||||
}
|
||||
}
|
||||
|
||||
if (url.starts_with("http://")) {
|
||||
if (wsSettings.webServerPort != 80) {
|
||||
url += ":" + std::to_string(wsSettings.webServerPort);
|
||||
}
|
||||
}
|
||||
|
||||
lv_label_set_text(labelUrlValue, url.c_str());
|
||||
}
|
||||
|
||||
public:
|
||||
void onCreate(TT_UNUSED AppContext& app) override {
|
||||
wsSettings = settings::webserver::loadOrGetDefault();
|
||||
originalSettings = wsSettings;
|
||||
}
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
lv_obj_t* toolbar = lvgl::toolbar_create(parent, app);
|
||||
|
||||
// Web Server Enable toggle
|
||||
switchWebServerEnabled = lvgl::toolbar_add_switch_action(toolbar);
|
||||
if (wsSettings.webServerEnabled) {
|
||||
lv_obj_add_state(switchWebServerEnabled, LV_STATE_CHECKED);
|
||||
}
|
||||
lv_obj_add_event_cb(switchWebServerEnabled, onWebServerEnabledSwitch, LV_EVENT_VALUE_CHANGED, this);
|
||||
|
||||
auto* main_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_width(main_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(main_wrapper, 1);
|
||||
|
||||
// WiFi Mode dropdown
|
||||
auto* wifi_mode_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(wifi_mode_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(wifi_mode_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(wifi_mode_wrapper, 0, LV_STATE_DEFAULT);
|
||||
auto* wifi_mode_label = lv_label_create(wifi_mode_wrapper);
|
||||
lv_label_set_text(wifi_mode_label, "WiFi Mode");
|
||||
lv_obj_align(wifi_mode_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
dropdownWifiMode = lv_dropdown_create(wifi_mode_wrapper);
|
||||
lv_obj_align(dropdownWifiMode, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_dropdown_set_options(dropdownWifiMode, "Station\nAccess Point");
|
||||
lv_dropdown_set_selected(dropdownWifiMode, static_cast<uint32_t>(wsSettings.wifiMode));
|
||||
lv_obj_add_event_cb(dropdownWifiMode, onWifiModeChanged, LV_EVENT_VALUE_CHANGED, this);
|
||||
|
||||
// AP Open Network toggle
|
||||
auto* ap_open_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(ap_open_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(ap_open_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(ap_open_wrapper, 0, LV_STATE_DEFAULT);
|
||||
auto* ap_open_label = lv_label_create(ap_open_wrapper);
|
||||
lv_label_set_text(ap_open_label, "AP Open Network");
|
||||
lv_obj_align(ap_open_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
switchApOpenNetwork = lv_switch_create(ap_open_wrapper);
|
||||
if (wsSettings.apOpenNetwork) lv_obj_add_state(switchApOpenNetwork, LV_STATE_CHECKED);
|
||||
lv_obj_align(switchApOpenNetwork, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(switchApOpenNetwork, onApOpenNetworkSwitch, LV_EVENT_VALUE_CHANGED, this);
|
||||
|
||||
// AP Password
|
||||
auto* ap_pass_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(ap_pass_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(ap_pass_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(ap_pass_wrapper, 0, LV_STATE_DEFAULT);
|
||||
auto* ap_pass_label = lv_label_create(ap_pass_wrapper);
|
||||
lv_label_set_text(ap_pass_label, "AP Password");
|
||||
lv_obj_align(ap_pass_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
textAreaApPassword = lv_textarea_create(ap_pass_wrapper);
|
||||
lv_obj_set_width(textAreaApPassword, 120);
|
||||
lv_obj_align(textAreaApPassword, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_textarea_set_one_line(textAreaApPassword, true);
|
||||
lv_textarea_set_max_length(textAreaApPassword, 64);
|
||||
lv_textarea_set_password_mode(textAreaApPassword, true);
|
||||
lv_textarea_set_text(textAreaApPassword, wsSettings.apPassword.c_str());
|
||||
lv_obj_add_event_cb(textAreaApPassword, onApPasswordChanged, LV_EVENT_VALUE_CHANGED, this);
|
||||
// Disable password field if open network is enabled
|
||||
if (wsSettings.apOpenNetwork) {
|
||||
lv_obj_add_state(textAreaApPassword, LV_STATE_DISABLED);
|
||||
lv_obj_remove_flag(textAreaApPassword, LV_OBJ_FLAG_CLICKABLE);
|
||||
}
|
||||
|
||||
// Web Server Authentication Enable toggle
|
||||
auto* ws_auth_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(ws_auth_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(ws_auth_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(ws_auth_wrapper, 0, LV_STATE_DEFAULT);
|
||||
auto* ws_auth_label = lv_label_create(ws_auth_wrapper);
|
||||
lv_label_set_text(ws_auth_label, "Require Authentication");
|
||||
lv_obj_align(ws_auth_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
switchWebServerAuthEnabled = lv_switch_create(ws_auth_wrapper);
|
||||
if (wsSettings.webServerAuthEnabled) lv_obj_add_state(switchWebServerAuthEnabled, LV_STATE_CHECKED);
|
||||
lv_obj_align(switchWebServerAuthEnabled, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(switchWebServerAuthEnabled, onWebServerAuthEnabledSwitch, LV_EVENT_VALUE_CHANGED, this);
|
||||
|
||||
// WebServer Username
|
||||
auto* ws_user_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(ws_user_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(ws_user_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(ws_user_wrapper, 0, LV_STATE_DEFAULT);
|
||||
auto* ws_user_label = lv_label_create(ws_user_wrapper);
|
||||
lv_label_set_text(ws_user_label, "Username");
|
||||
lv_obj_align(ws_user_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
textAreaWebServerUsername = lv_textarea_create(ws_user_wrapper);
|
||||
lv_obj_set_width(textAreaWebServerUsername, 120);
|
||||
lv_obj_align(textAreaWebServerUsername, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_textarea_set_one_line(textAreaWebServerUsername, true);
|
||||
lv_textarea_set_max_length(textAreaWebServerUsername, 32);
|
||||
lv_textarea_set_text(textAreaWebServerUsername, wsSettings.webServerUsername.c_str());
|
||||
lv_obj_add_event_cb(textAreaWebServerUsername, onCredentialChanged, LV_EVENT_VALUE_CHANGED, this);
|
||||
|
||||
// WebServer Password
|
||||
auto* ws_pass_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(ws_pass_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(ws_pass_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(ws_pass_wrapper, 0, LV_STATE_DEFAULT);
|
||||
auto* ws_pass_label = lv_label_create(ws_pass_wrapper);
|
||||
lv_label_set_text(ws_pass_label, "Password");
|
||||
lv_obj_align(ws_pass_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
textAreaWebServerPassword = lv_textarea_create(ws_pass_wrapper);
|
||||
lv_obj_set_width(textAreaWebServerPassword, 120);
|
||||
lv_obj_align(textAreaWebServerPassword, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_textarea_set_one_line(textAreaWebServerPassword, true);
|
||||
lv_textarea_set_max_length(textAreaWebServerPassword, 64);
|
||||
lv_textarea_set_password_mode(textAreaWebServerPassword, true);
|
||||
lv_textarea_set_text(textAreaWebServerPassword, wsSettings.webServerPassword.c_str());
|
||||
lv_obj_add_event_cb(textAreaWebServerPassword, onCredentialChanged, LV_EVENT_VALUE_CHANGED, this);
|
||||
|
||||
// URL Display
|
||||
auto* url_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(url_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(url_wrapper, 10, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(url_wrapper, 1, LV_STATE_DEFAULT);
|
||||
lv_obj_set_flex_flow(url_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_flex_cross_place(url_wrapper, LV_FLEX_ALIGN_START, 0);
|
||||
|
||||
labelUrl = lv_label_create(url_wrapper);
|
||||
lv_label_set_text(labelUrl, "Web Server URL:");
|
||||
|
||||
labelUrlValue = lv_label_create(url_wrapper);
|
||||
lv_obj_set_style_text_color(labelUrlValue, lv_palette_main(LV_PALETTE_BLUE), 0);
|
||||
|
||||
updateUrlDisplay();
|
||||
|
||||
// Sync Assets button
|
||||
auto* sync_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(sync_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(sync_wrapper, 10, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(sync_wrapper, 1, LV_STATE_DEFAULT);
|
||||
lv_obj_set_flex_flow(sync_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_flex_cross_place(sync_wrapper, LV_FLEX_ALIGN_START, 0);
|
||||
|
||||
auto* sync_label = lv_label_create(sync_wrapper);
|
||||
lv_label_set_text(sync_label, "Asset Synchronization");
|
||||
lv_obj_set_style_text_font(sync_label, &lv_font_montserrat_14, 0);
|
||||
|
||||
auto* sync_info = lv_label_create(sync_wrapper);
|
||||
lv_label_set_long_mode(sync_info, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(sync_info, LV_PCT(95));
|
||||
lv_obj_set_style_text_color(sync_info, lv_palette_main(LV_PALETTE_GREY), 0);
|
||||
lv_label_set_text(sync_info, "Sync web assets between Data partition and SD card backup");
|
||||
|
||||
auto* sync_button = lv_btn_create(sync_wrapper);
|
||||
lv_obj_set_width(sync_button, LV_SIZE_CONTENT);
|
||||
auto* sync_button_label = lv_label_create(sync_button);
|
||||
lv_label_set_text(sync_button_label, "Sync Assets Now");
|
||||
lv_obj_center(sync_button_label);
|
||||
lv_obj_add_event_cb(sync_button, onSyncAssets, LV_EVENT_CLICKED, this);
|
||||
|
||||
// Info text
|
||||
auto* info_label = lv_label_create(main_wrapper);
|
||||
lv_label_set_long_mode(info_label, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(info_label, LV_PCT(95));
|
||||
lv_obj_set_style_text_color(info_label, lv_palette_main(LV_PALETTE_GREY), 0);
|
||||
lv_label_set_text(info_label,
|
||||
"WiFi Station credentials are managed separately.\n"
|
||||
"Use the WiFi menu to connect to networks.\n\n"
|
||||
"AP mode uses the password configured above.");
|
||||
}
|
||||
|
||||
void onHide(TT_UNUSED AppContext& app) override {
|
||||
if (updated) {
|
||||
// Read values from text areas
|
||||
if (textAreaApPassword) {
|
||||
wsSettings.apPassword = lv_textarea_get_text(textAreaApPassword);
|
||||
}
|
||||
if (textAreaWebServerUsername) {
|
||||
wsSettings.webServerUsername = lv_textarea_get_text(textAreaWebServerUsername);
|
||||
}
|
||||
if (textAreaWebServerPassword) {
|
||||
wsSettings.webServerPassword = lv_textarea_get_text(textAreaWebServerPassword);
|
||||
}
|
||||
|
||||
// Save to flash only (settings sync at boot handles SD restore)
|
||||
const auto copy = wsSettings;
|
||||
const bool wifiChanged = wifiSettingsChanged;
|
||||
const bool webServerChanged = webServerEnabledChanged;
|
||||
|
||||
getMainDispatcher().dispatch([copy, wifiChanged, webServerChanged]{
|
||||
// Save to flash (fast, low memory pressure)
|
||||
if (!settings::webserver::save(copy)) {
|
||||
LOGGER.warn("Failed to persist WebServer settings; changes may be lost on reboot");
|
||||
}
|
||||
|
||||
// Publish event immediately after save so WebServer cache refreshes BEFORE requests arrive
|
||||
service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged);
|
||||
|
||||
// Only reconnect WiFi if WiFi settings actually changed
|
||||
if (wifiChanged) {
|
||||
LOGGER.info("WiFi mode changed to {}", copy.wifiMode == settings::webserver::WiFiMode::AccessPoint ? "AP" : "Station");
|
||||
}
|
||||
|
||||
// Control WebServer service immediately
|
||||
if (webServerChanged) {
|
||||
LOGGER.info("WebServer {}", copy.webServerEnabled ? "enabling..." : "disabling...");
|
||||
service::webserver::setWebServerEnabled(copy.webServerEnabled);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "WebServerSettings",
|
||||
.appName = "Web Server",
|
||||
.appIcon = TT_ASSETS_APP_ICON_SETTINGS,
|
||||
.appCategory = Category::System,
|
||||
.createApp = create<WebServerSettingsApp>
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -9,39 +9,58 @@ namespace tt::network {
|
||||
|
||||
static const auto LOGGER = Logger("HttpServer");
|
||||
|
||||
static constexpr size_t INTERNAL_URI_HANDLER_COUNT = 2;
|
||||
|
||||
bool HttpServer::startInternal() {
|
||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||
config.stack_size = stackSize;
|
||||
config.server_port = port;
|
||||
config.uri_match_fn = matchUri;
|
||||
config.max_uri_handlers = handlers.size() + INTERNAL_URI_HANDLER_COUNT;
|
||||
|
||||
if (httpd_start(&server, &config) != ESP_OK) {
|
||||
LOGGER.error("Failed to start http server on port {}", port);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool allRegistered = true;
|
||||
for (std::vector<httpd_uri_t>::reference handler : handlers) {
|
||||
httpd_register_uri_handler(server, &handler);
|
||||
if (httpd_register_uri_handler(server, &handler) != ESP_OK) {
|
||||
LOGGER.error("Failed to register URI handler: {}", handler.uri);
|
||||
allRegistered = false;
|
||||
}
|
||||
}
|
||||
if (!allRegistered) {
|
||||
httpd_stop(server);
|
||||
server = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
LOGGER.info("Started on port {}", config.server_port);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void HttpServer::stopInternal() {
|
||||
LOGGER.info("Stopping server");
|
||||
if (server != nullptr && httpd_stop(server) != ESP_OK) {
|
||||
LOGGER.warn("Error while stopping");
|
||||
server = nullptr;
|
||||
if (server != nullptr) {
|
||||
if (httpd_stop(server) == ESP_OK) {
|
||||
server = nullptr;
|
||||
} else {
|
||||
LOGGER.warn("Error while stopping");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void HttpServer::start() {
|
||||
bool HttpServer::start() {
|
||||
auto lock = mutex.asScopedLock();
|
||||
lock.lock();
|
||||
|
||||
startInternal();
|
||||
if (isStarted()) {
|
||||
LOGGER.warn("Already started");
|
||||
return true;
|
||||
}
|
||||
|
||||
return startInternal();
|
||||
}
|
||||
|
||||
void HttpServer::stop() {
|
||||
@@ -50,6 +69,7 @@ void HttpServer::stop() {
|
||||
|
||||
if (!isStarted()) {
|
||||
LOGGER.warn("Not started");
|
||||
return;
|
||||
}
|
||||
|
||||
stopInternal();
|
||||
|
||||
@@ -83,18 +83,35 @@ std::unique_ptr<char[]> receiveByteArray(httpd_req_t* request, size_t length, si
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
constexpr int MAX_TIMEOUT_RETRIES = 5;
|
||||
int timeout_retries = 0;
|
||||
while (bytesRead < length) {
|
||||
size_t read_size = length - bytesRead;
|
||||
size_t bytes_received = httpd_req_recv(request, buffer + bytesRead, read_size);
|
||||
int bytes_received = httpd_req_recv(request, buffer + bytesRead, read_size);
|
||||
if (bytes_received == HTTPD_SOCK_ERR_TIMEOUT) {
|
||||
// Timeout - retry with backoff
|
||||
timeout_retries++;
|
||||
if (timeout_retries >= MAX_TIMEOUT_RETRIES) {
|
||||
LOGGER.warn("Recv timeout after {} retries, read {}/{} bytes", timeout_retries, bytesRead, length);
|
||||
free(buffer);
|
||||
return nullptr;
|
||||
}
|
||||
LOGGER.warn("Recv timeout, retry {}/{}", timeout_retries, MAX_TIMEOUT_RETRIES);
|
||||
vTaskDelay(pdMS_TO_TICKS(100 * timeout_retries)); // Exponential backoff
|
||||
continue;
|
||||
}
|
||||
if (bytes_received <= 0) {
|
||||
LOGGER.warn("Received error {} after reading {}/{} bytes", bytes_received, bytesRead, length);
|
||||
free(buffer);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Successful read - reset timeout counter
|
||||
timeout_retries = 0;
|
||||
bytesRead += bytes_received;
|
||||
}
|
||||
|
||||
return std::unique_ptr<char[]>(std::move(buffer));
|
||||
return std::unique_ptr<char[]>(buffer);
|
||||
}
|
||||
|
||||
std::string receiveTextUntil(httpd_req_t* request, const std::string& terminator) {
|
||||
@@ -131,7 +148,7 @@ std::map<std::string, std::string> parseContentDisposition(const std::vector<std
|
||||
|
||||
auto parseable = content_disposition_header->substr(prefix.size());
|
||||
auto parts = string::split(parseable, "; ");
|
||||
for (auto part : parts) {
|
||||
for (const auto& part : parts) {
|
||||
auto key_value = string::split(part, "=");
|
||||
if (key_value.size() == 2) {
|
||||
// Trim trailing newlines
|
||||
@@ -150,7 +167,7 @@ std::map<std::string, std::string> parseContentDisposition(const std::vector<std
|
||||
bool readAndDiscardOrSendError(httpd_req_t* request, const std::string& toRead) {
|
||||
size_t bytes_read;
|
||||
auto buffer = receiveByteArray(request, toRead.length(), bytes_read);
|
||||
if (bytes_read != toRead.length()) {
|
||||
if (buffer == nullptr || bytes_read != toRead.length()) {
|
||||
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "failed to read discardable data");
|
||||
return false;
|
||||
}
|
||||
@@ -184,7 +201,7 @@ size_t receiveFile(httpd_req_t* request, size_t length, const std::string& fileP
|
||||
LOGGER.error("Receive failed");
|
||||
break;
|
||||
}
|
||||
if (fwrite(buffer, 1, receive_chunk_size, file) != receive_chunk_size) {
|
||||
if (fwrite(buffer, 1, receive_chunk_size, file) != (size_t)receive_chunk_size) {
|
||||
LOGGER.error("Failed to write all bytes");
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
#ifdef ESP_PLATFORM
|
||||
|
||||
#include <Tactility/service/webserver/AssetVersion.h>
|
||||
|
||||
#include <Tactility/file/File.h>
|
||||
#include <Tactility/Logger.h>
|
||||
|
||||
#include <cJSON.h>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <format>
|
||||
#include <esp_random.h>
|
||||
|
||||
namespace tt::service::webserver {
|
||||
|
||||
static const auto LOGGER = tt::Logger("AssetVersion");
|
||||
constexpr auto* DATA_VERSION_FILE = "/data/webserver/version.json";
|
||||
constexpr auto* SD_VERSION_FILE = "/sdcard/tactility/webserver/version.json";
|
||||
constexpr auto* DATA_ASSETS_DIR = "/data/webserver";
|
||||
constexpr auto* SD_ASSETS_DIR = "/sdcard/tactility/webserver";
|
||||
|
||||
static bool loadVersionFromFile(const char* path, AssetVersion& version) {
|
||||
if (!file::isFile(path)) {
|
||||
LOGGER.warn("Version file not found: {}", path);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read file content
|
||||
std::string content;
|
||||
{
|
||||
auto lock = file::getLock(path);
|
||||
lock->lock(portMAX_DELAY);
|
||||
|
||||
FILE* fp = fopen(path, "r");
|
||||
if (!fp) {
|
||||
LOGGER.error("Failed to open version file: {}", path);
|
||||
lock->unlock();
|
||||
return false;
|
||||
}
|
||||
|
||||
char buffer[256];
|
||||
size_t bytesRead = fread(buffer, 1, sizeof(buffer) - 1, fp);
|
||||
bool readError = ferror(fp) != 0;
|
||||
fclose(fp);
|
||||
lock->unlock();
|
||||
|
||||
if (readError) {
|
||||
LOGGER.error("Error reading version file: {}", path);
|
||||
return false;
|
||||
}
|
||||
if (bytesRead == 0) {
|
||||
LOGGER.error("Version file is empty: {}", path);
|
||||
return false;
|
||||
}
|
||||
buffer[bytesRead] = '\0';
|
||||
content = buffer;
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
cJSON* json = cJSON_Parse(content.c_str());
|
||||
if (json == nullptr) {
|
||||
LOGGER.error("Failed to parse version JSON: {}", path);
|
||||
return false;
|
||||
}
|
||||
|
||||
cJSON* versionItem = cJSON_GetObjectItem(json, "version");
|
||||
if (versionItem == nullptr || !cJSON_IsNumber(versionItem)) {
|
||||
LOGGER.error("Invalid version JSON format: {}", path);
|
||||
cJSON_Delete(json);
|
||||
return false;
|
||||
}
|
||||
|
||||
double versionValue = versionItem->valuedouble;
|
||||
if (versionValue < 0 || versionValue > UINT32_MAX) {
|
||||
LOGGER.error("Version out of valid range [0, {}]: {}", UINT32_MAX, path);
|
||||
cJSON_Delete(json);
|
||||
return false;
|
||||
}
|
||||
version.version = static_cast<uint32_t>(versionValue);
|
||||
cJSON_Delete(json);
|
||||
|
||||
LOGGER.info("Loaded version {} from {}", version.version, path);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool saveVersionToFile(const char* path, const AssetVersion& version) {
|
||||
// Create directory if it doesn't exist
|
||||
std::string dirPath(path);
|
||||
size_t lastSlash = dirPath.find_last_of('/');
|
||||
if (lastSlash != std::string::npos) {
|
||||
dirPath = dirPath.substr(0, lastSlash);
|
||||
if (!file::isDirectory(dirPath.c_str())) {
|
||||
if (!file::findOrCreateDirectory(dirPath.c_str(), 0755)) {
|
||||
LOGGER.error("Failed to create directory: {}", dirPath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create JSON
|
||||
cJSON* json = cJSON_CreateObject();
|
||||
if (json == nullptr) {
|
||||
LOGGER.error("Failed to create JSON object for version");
|
||||
return false;
|
||||
}
|
||||
cJSON_AddNumberToObject(json, "version", version.version);
|
||||
|
||||
char* jsonString = cJSON_Print(json);
|
||||
if (jsonString == nullptr) {
|
||||
LOGGER.error("Failed to serialize version JSON");
|
||||
cJSON_Delete(json);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write to file
|
||||
bool success = false;
|
||||
{
|
||||
auto lock = file::getLock(path);
|
||||
lock->lock(portMAX_DELAY);
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
if (fp) {
|
||||
size_t len = strlen(jsonString);
|
||||
size_t written = fwrite(jsonString, 1, len, fp);
|
||||
success = (written == len);
|
||||
if (success) {
|
||||
if (fflush(fp) != 0) {
|
||||
LOGGER.error("Failed to flush version file: {}", path);
|
||||
success = false;
|
||||
} else {
|
||||
int fd = fileno(fp);
|
||||
if (fd >= 0 && fsync(fd) != 0) {
|
||||
LOGGER.error("Failed to fsync version file: {}", path);
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose(fp);
|
||||
}
|
||||
lock->unlock();
|
||||
}
|
||||
|
||||
cJSON_free(jsonString);
|
||||
cJSON_Delete(json);
|
||||
|
||||
if (success) {
|
||||
LOGGER.info("Saved version {} to {}", version.version, path);
|
||||
} else {
|
||||
LOGGER.error("Failed to write version file: {}", path);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
bool loadDataVersion(AssetVersion& version) {
|
||||
return loadVersionFromFile(DATA_VERSION_FILE, version);
|
||||
}
|
||||
|
||||
bool loadSdVersion(AssetVersion& version) {
|
||||
return loadVersionFromFile(SD_VERSION_FILE, version);
|
||||
}
|
||||
|
||||
bool saveDataVersion(const AssetVersion& version) {
|
||||
return saveVersionToFile(DATA_VERSION_FILE, version);
|
||||
}
|
||||
|
||||
bool saveSdVersion(const AssetVersion& version) {
|
||||
return saveVersionToFile(SD_VERSION_FILE, version);
|
||||
}
|
||||
|
||||
bool hasDataAssets() {
|
||||
return file::isDirectory(DATA_ASSETS_DIR);
|
||||
}
|
||||
|
||||
bool hasSdAssets() {
|
||||
return file::isDirectory(SD_ASSETS_DIR);
|
||||
}
|
||||
|
||||
static bool copyDirectory(const char* src, const char* dst, int depth = 0) {
|
||||
constexpr int MAX_DEPTH = 16;
|
||||
if (depth >= MAX_DEPTH) {
|
||||
LOGGER.error("Max directory depth exceeded: {}", src);
|
||||
return false;
|
||||
}
|
||||
LOGGER.info("Copying directory: {} -> {}", src, dst);
|
||||
|
||||
// Create destination directory
|
||||
if (!file::isDirectory(dst)) {
|
||||
if (!file::findOrCreateDirectory(dst, 0755)) {
|
||||
LOGGER.error("Failed to create destination directory: {}", dst);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// List source directory and copy each entry
|
||||
bool copySuccess = true;
|
||||
bool listSuccess = file::listDirectory(src, [&](const dirent& entry) {
|
||||
// Skip "." and ".." entries (though listDirectory should already filter these)
|
||||
if (strcmp(entry.d_name, ".") == 0 || strcmp(entry.d_name, "..") == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::string srcPath = file::getChildPath(src, entry.d_name);
|
||||
std::string dstPath = file::getChildPath(dst, entry.d_name);
|
||||
|
||||
if (entry.d_type == file::TT_DT_DIR) {
|
||||
// Recursively copy subdirectory
|
||||
if (!copyDirectory(srcPath.c_str(), dstPath.c_str(), depth + 1)) {
|
||||
copySuccess = false;
|
||||
}
|
||||
} else if (entry.d_type == file::TT_DT_REG) {
|
||||
// Copy file using atomic temp file approach
|
||||
auto lock = file::getLock(srcPath);
|
||||
lock->lock(portMAX_DELAY);
|
||||
|
||||
// Generate unique temp file path
|
||||
std::string tempPath = std::format("{}.tmp.{}", dstPath, esp_random());
|
||||
|
||||
FILE* srcFile = fopen(srcPath.c_str(), "rb");
|
||||
if (!srcFile) {
|
||||
LOGGER.error("Failed to open source file: {}", srcPath);
|
||||
lock->unlock();
|
||||
copySuccess = false;
|
||||
return;
|
||||
}
|
||||
|
||||
FILE* tempFile = fopen(tempPath.c_str(), "wb");
|
||||
if (!tempFile) {
|
||||
LOGGER.error("Failed to create temp file: {}", tempPath);
|
||||
fclose(srcFile);
|
||||
lock->unlock();
|
||||
copySuccess = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy in chunks
|
||||
char buffer[512];
|
||||
size_t bytesRead;
|
||||
bool fileCopySuccess = true;
|
||||
while ((bytesRead = fread(buffer, 1, sizeof(buffer), srcFile)) > 0) {
|
||||
size_t bytesWritten = fwrite(buffer, 1, bytesRead, tempFile);
|
||||
if (bytesWritten != bytesRead) {
|
||||
LOGGER.error("Failed to write to temp file: {}", tempPath);
|
||||
fileCopySuccess = false;
|
||||
copySuccess = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (fileCopySuccess && ferror(srcFile)) {
|
||||
LOGGER.error("Error reading source file: {}", srcPath);
|
||||
fileCopySuccess = false;
|
||||
copySuccess = false;
|
||||
}
|
||||
|
||||
fclose(srcFile);
|
||||
|
||||
// Flush and sync temp file before closing
|
||||
if (fileCopySuccess) {
|
||||
if (fflush(tempFile) != 0) {
|
||||
LOGGER.error("Failed to flush temp file: {}", tempPath);
|
||||
fileCopySuccess = false;
|
||||
copySuccess = false;
|
||||
} else {
|
||||
int fd = fileno(tempFile);
|
||||
if (fd >= 0 && fsync(fd) != 0) {
|
||||
LOGGER.error("Failed to fsync temp file: {}", tempPath);
|
||||
fileCopySuccess = false;
|
||||
copySuccess = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fclose(tempFile);
|
||||
|
||||
if (fileCopySuccess) {
|
||||
// Atomically rename temp file to destination
|
||||
if (rename(tempPath.c_str(), dstPath.c_str()) != 0) {
|
||||
LOGGER.error("Failed to rename temp file {} to {}", tempPath, dstPath);
|
||||
remove(tempPath.c_str());
|
||||
fileCopySuccess = false;
|
||||
copySuccess = false;
|
||||
}
|
||||
} else {
|
||||
// Clean up temp file on failure
|
||||
remove(tempPath.c_str());
|
||||
}
|
||||
|
||||
lock->unlock();
|
||||
|
||||
if (fileCopySuccess) {
|
||||
LOGGER.info("Copied file: {}", entry.d_name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!listSuccess) {
|
||||
LOGGER.error("Failed to list source directory: {}", src);
|
||||
return false;
|
||||
}
|
||||
|
||||
return copySuccess;
|
||||
}
|
||||
|
||||
bool syncAssets() {
|
||||
LOGGER.info("Starting asset synchronization...");
|
||||
|
||||
// Check if Data partition and SD card exist
|
||||
bool dataExists = hasDataAssets();
|
||||
bool sdExists = hasSdAssets();
|
||||
|
||||
// FIRST BOOT SCENARIO: Data has version 0, SD card is missing
|
||||
if (dataExists && !sdExists) {
|
||||
LOGGER.info("First boot - Data exists but SD card backup missing");
|
||||
LOGGER.warn("Skipping SD backup during boot - will be created on first settings save");
|
||||
LOGGER.warn("This avoids watchdog timeout if SD card is slow or corrupted");
|
||||
return true; // Don't block boot - defer copy to runtime
|
||||
}
|
||||
|
||||
// NO SD CARD: Just ensure Data has default structure
|
||||
if (!sdExists) {
|
||||
LOGGER.warn("No SD card available - creating default Data structure if needed");
|
||||
if (!dataExists) {
|
||||
if (!file::findOrCreateDirectory(DATA_ASSETS_DIR, 0755)) {
|
||||
LOGGER.error("Failed to create Data assets directory");
|
||||
return false;
|
||||
}
|
||||
AssetVersion defaultVersion(0); // Start at version 0 - SD card updates will be version 1+
|
||||
if (!saveDataVersion(defaultVersion)) {
|
||||
LOGGER.error("Failed to save default Data version");
|
||||
return false;
|
||||
}
|
||||
LOGGER.info("Created default Data assets structure (version 0)");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST-FLASH RECOVERY: Data empty but SD card exists
|
||||
if (!dataExists) {
|
||||
LOGGER.info("Data partition empty - copying from SD card (recovery mode)");
|
||||
if (!copyDirectory(SD_ASSETS_DIR, DATA_ASSETS_DIR)) {
|
||||
LOGGER.error("Failed to copy assets from SD card to Data");
|
||||
return false;
|
||||
}
|
||||
LOGGER.info("Recovery complete - assets restored from SD card");
|
||||
return true;
|
||||
}
|
||||
|
||||
// NORMAL OPERATION: Both exist - compare versions
|
||||
AssetVersion dataVersion, sdVersion;
|
||||
bool hasDataVer = loadDataVersion(dataVersion);
|
||||
bool hasSdVer = loadSdVersion(sdVersion);
|
||||
|
||||
if (!hasDataVer) {
|
||||
LOGGER.warn("No Data version.json - assuming version 0");
|
||||
dataVersion.version = 0;
|
||||
if (!saveDataVersion(dataVersion)) {
|
||||
LOGGER.warn("Failed to save default Data version (non-fatal)");
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasSdVer) {
|
||||
LOGGER.warn("No SD version.json - assuming version 0");
|
||||
sdVersion.version = 0;
|
||||
// DON'T save to SD during boot - defer to runtime
|
||||
LOGGER.warn("Skipping SD version.json creation during boot - will be created on first settings save");
|
||||
}
|
||||
|
||||
LOGGER.info("Version comparison - Data: {}, SD: {}", dataVersion.version, sdVersion.version);
|
||||
|
||||
if (sdVersion.version > dataVersion.version) {
|
||||
// Firmware update - copy SD -> Data
|
||||
LOGGER.info("SD card newer (v{} > v{}) - copying assets SD -> Data (firmware update)",
|
||||
sdVersion.version, dataVersion.version);
|
||||
if (!copyDirectory(SD_ASSETS_DIR, DATA_ASSETS_DIR)) {
|
||||
LOGGER.error("Failed to copy assets from SD to Data");
|
||||
return false;
|
||||
}
|
||||
LOGGER.info("Firmware update complete - assets updated from SD card");
|
||||
} else if (dataVersion.version > sdVersion.version) {
|
||||
// User customization - backup Data -> SD
|
||||
LOGGER.warn("Data newer (v{} > v{}) - deferring SD backup to avoid boot watchdog",
|
||||
dataVersion.version, sdVersion.version);
|
||||
LOGGER.warn("SD backup will occur on first WebServer settings save");
|
||||
return true; // Don't block boot - defer copy to runtime
|
||||
} else {
|
||||
LOGGER.info("Versions match (v{}) - no sync needed", dataVersion.version);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
||||
#include <Tactility/settings/WebServerSettings.h>
|
||||
#include <Tactility/file/PropertiesFile.h>
|
||||
#include <Tactility/file/File.h>
|
||||
#include <Tactility/Logger.h>
|
||||
|
||||
#include <charconv>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <esp_mac.h>
|
||||
#include <esp_wifi.h>
|
||||
#include <esp_random.h>
|
||||
#else
|
||||
#include <random>
|
||||
#endif
|
||||
|
||||
namespace tt::settings::webserver {
|
||||
|
||||
static const auto LOGGER = tt::Logger("WebServerSettings");
|
||||
constexpr auto* SETTINGS_FILE = "/data/service/webserver/settings.properties";
|
||||
|
||||
// Property keys
|
||||
constexpr auto* KEY_WIFI_ENABLED = "wifiEnabled";
|
||||
constexpr auto* KEY_WIFI_MODE = "wifiMode";
|
||||
constexpr auto* KEY_AP_SSID = "apSsid";
|
||||
constexpr auto* KEY_AP_PASSWORD = "apPassword";
|
||||
constexpr auto* KEY_AP_OPEN_NETWORK = "apOpenNetwork";
|
||||
constexpr auto* KEY_AP_CHANNEL = "apChannel";
|
||||
constexpr auto* KEY_WEBSERVER_ENABLED = "webServerEnabled";
|
||||
constexpr auto* KEY_WEBSERVER_PORT = "webServerPort";
|
||||
constexpr auto* KEY_WEBSERVER_AUTH_ENABLED = "webServerAuthEnabled";
|
||||
constexpr auto* KEY_WEBSERVER_USERNAME = "webServerUsername";
|
||||
constexpr auto* KEY_WEBSERVER_PASSWORD = "webServerPassword";
|
||||
|
||||
std::string generateDefaultApSsid() {
|
||||
#ifdef ESP_PLATFORM
|
||||
uint8_t mac[6];
|
||||
if (esp_read_mac(mac, ESP_MAC_WIFI_STA) != ESP_OK) {
|
||||
return "Tactility-0000";
|
||||
}
|
||||
char ssid[16];
|
||||
snprintf(ssid, sizeof(ssid), "Tactility-%02X%02X", mac[2] ^ mac[3], mac[4] ^ mac[5]);
|
||||
return std::string(ssid);
|
||||
#else
|
||||
return "Tactility-0000";
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Generate a cryptographically secure random string for credentials
|
||||
* @param length The desired length of the string
|
||||
* @return A random alphanumeric string
|
||||
*/
|
||||
static std::string generateRandomCredential(size_t length) {
|
||||
static constexpr char charset[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
static constexpr size_t charsetSize = sizeof(charset) - 1;
|
||||
|
||||
std::string result;
|
||||
result.reserve(length);
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
uint32_t randomValue = esp_random();
|
||||
result += charset[randomValue % charsetSize];
|
||||
}
|
||||
#else
|
||||
static std::random_device rd;
|
||||
static std::mt19937 gen(rd());
|
||||
std::uniform_int_distribution<> dis(0, charsetSize - 1);
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
result += charset[dis(gen)];
|
||||
}
|
||||
#endif
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if a credential value is empty (needs auto-generation)
|
||||
* @param value The credential value to check
|
||||
* @return true if the credential is empty and needs generation
|
||||
*/
|
||||
static bool isEmptyCredential(const std::string& value) {
|
||||
return value.empty();
|
||||
}
|
||||
|
||||
bool load(WebServerSettings& settings) {
|
||||
std::map<std::string, std::string> map;
|
||||
if (!file::loadPropertiesFile(SETTINGS_FILE, map)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse all settings from the map
|
||||
auto wifi_enabled = map.find(KEY_WIFI_ENABLED);
|
||||
auto wifi_mode = map.find(KEY_WIFI_MODE);
|
||||
auto ap_ssid = map.find(KEY_AP_SSID);
|
||||
auto ap_password = map.find(KEY_AP_PASSWORD);
|
||||
auto ap_open_network = map.find(KEY_AP_OPEN_NETWORK);
|
||||
auto ap_channel = map.find(KEY_AP_CHANNEL);
|
||||
auto webserver_enabled = map.find(KEY_WEBSERVER_ENABLED);
|
||||
auto webserver_port = map.find(KEY_WEBSERVER_PORT);
|
||||
auto webserver_auth_enabled = map.find(KEY_WEBSERVER_AUTH_ENABLED);
|
||||
auto webserver_username = map.find(KEY_WEBSERVER_USERNAME);
|
||||
auto webserver_password = map.find(KEY_WEBSERVER_PASSWORD);
|
||||
|
||||
// WiFi settings
|
||||
settings.wifiEnabled = (wifi_enabled != map.end())
|
||||
? (wifi_enabled->second == "1" || wifi_enabled->second == "true")
|
||||
: false; // Default disabled
|
||||
|
||||
settings.wifiMode = (wifi_mode != map.end() && wifi_mode->second == "1")
|
||||
? WiFiMode::AccessPoint
|
||||
: WiFiMode::Station;
|
||||
|
||||
auto parseInt = [](const std::string& value, int min, int max, int fallback) -> int {
|
||||
int v = 0;
|
||||
auto [ptr, ec] = std::from_chars(value.data(), value.data() + value.size(), v);
|
||||
if (ec != std::errc{} || v < min || v > max) {
|
||||
return fallback;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
|
||||
// AP mode settings
|
||||
settings.apSsid = (ap_ssid != map.end() && !ap_ssid->second.empty())
|
||||
? ap_ssid->second
|
||||
: generateDefaultApSsid();
|
||||
settings.apPassword = (ap_password != map.end()) ? ap_password->second : "";
|
||||
settings.apOpenNetwork = (ap_open_network != map.end())
|
||||
? (ap_open_network->second == "1" || ap_open_network->second == "true")
|
||||
: false;
|
||||
settings.apChannel = (ap_channel != map.end())
|
||||
? static_cast<uint8_t>(parseInt(ap_channel->second, 1, 13, 1))
|
||||
: 1;
|
||||
|
||||
// Security: If AP password is empty, generate a strong random password.
|
||||
// Skip this if user explicitly wants an open network.
|
||||
// Note: We only auto-generate for EMPTY passwords, not user-set ones.
|
||||
if (!settings.apOpenNetwork && isEmptyCredential(settings.apPassword)) {
|
||||
LOGGER.info("AP password is empty - generating secure random password");
|
||||
|
||||
// Generate 12-character random password (alphanumeric, ~71 bits of entropy)
|
||||
// WPA2 requires 8-63 characters, so 12 is well within range
|
||||
settings.apPassword = generateRandomCredential(12);
|
||||
|
||||
// Persist the generated password immediately
|
||||
map[KEY_AP_PASSWORD] = settings.apPassword;
|
||||
if (file::savePropertiesFile(SETTINGS_FILE, map)) {
|
||||
LOGGER.info("Generated and saved new secure AP password");
|
||||
} else {
|
||||
LOGGER.error("Failed to save generated AP password");
|
||||
}
|
||||
}
|
||||
|
||||
// Web server settings
|
||||
settings.webServerEnabled = (webserver_enabled != map.end())
|
||||
? (webserver_enabled->second == "1" || webserver_enabled->second == "true")
|
||||
: false;
|
||||
|
||||
settings.webServerPort = (webserver_port != map.end())
|
||||
? static_cast<uint16_t>(parseInt(webserver_port->second, 1, 65535, 80))
|
||||
: 80;
|
||||
|
||||
// Web server auth settings
|
||||
settings.webServerAuthEnabled = (webserver_auth_enabled != map.end())
|
||||
? (webserver_auth_enabled->second == "1" || webserver_auth_enabled->second == "true")
|
||||
: false;
|
||||
|
||||
// Load credentials from file, defaulting to empty if not present
|
||||
settings.webServerUsername = (webserver_username != map.end()) ? webserver_username->second : "";
|
||||
settings.webServerPassword = (webserver_password != map.end()) ? webserver_password->second : "";
|
||||
|
||||
// Security: If auth is enabled but credentials are empty,
|
||||
// generate strong random credentials and persist them immediately.
|
||||
// Note: We only auto-generate for EMPTY credentials, allowing users to set their own.
|
||||
if (settings.webServerAuthEnabled &&
|
||||
(isEmptyCredential(settings.webServerUsername) || isEmptyCredential(settings.webServerPassword))) {
|
||||
|
||||
LOGGER.info("Auth enabled with empty credentials - generating secure random credentials");
|
||||
|
||||
// Generate 12-character random credentials (alphanumeric, ~71 bits of entropy each)
|
||||
settings.webServerUsername = generateRandomCredential(12);
|
||||
settings.webServerPassword = generateRandomCredential(12);
|
||||
|
||||
// Persist the generated credentials immediately
|
||||
// We need to save these to the file so they're consistent across reboots
|
||||
map[KEY_WEBSERVER_USERNAME] = settings.webServerUsername;
|
||||
map[KEY_WEBSERVER_PASSWORD] = settings.webServerPassword;
|
||||
if (file::savePropertiesFile(SETTINGS_FILE, map)) {
|
||||
LOGGER.info("Generated and saved new secure credentials");
|
||||
} else {
|
||||
LOGGER.error("Failed to save generated credentials - auth may be inconsistent across reboots");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
WebServerSettings getDefault() {
|
||||
return WebServerSettings{
|
||||
.wifiEnabled = false, // Default WiFi OFF
|
||||
.wifiMode = WiFiMode::Station,
|
||||
.apSsid = generateDefaultApSsid(),
|
||||
.apPassword = "", // Empty - will be auto-generated on first use (unless apOpenNetwork)
|
||||
.apOpenNetwork = false, // Default to secured network
|
||||
.apChannel = 1,
|
||||
.webServerEnabled = false, // Default WebServer OFF for security
|
||||
.webServerPort = 80,
|
||||
.webServerAuthEnabled = false, // Auth disabled by default
|
||||
.webServerUsername = "", // Empty - will be generated if auth is enabled
|
||||
.webServerPassword = "" // Empty - will be generated if auth is enabled
|
||||
};
|
||||
}
|
||||
|
||||
WebServerSettings loadOrGetDefault() {
|
||||
WebServerSettings settings;
|
||||
|
||||
bool loadedFromFlash = load(settings);
|
||||
if (!loadedFromFlash) {
|
||||
// First boot - use defaults (WiFi OFF, WebServer OFF)
|
||||
settings = getDefault();
|
||||
// Save defaults to flash so toggle states persist
|
||||
if (save(settings)) {
|
||||
LOGGER.info("First boot - saved default settings (WiFi OFF WebServer OFF)");
|
||||
} else {
|
||||
LOGGER.warn("First boot - failed to save default settings to flash");
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
bool save(const WebServerSettings& settings) {
|
||||
std::map<std::string, std::string> map;
|
||||
|
||||
// WiFi settings
|
||||
map[KEY_WIFI_ENABLED] = settings.wifiEnabled ? "1" : "0";
|
||||
map[KEY_WIFI_MODE] = (settings.wifiMode == WiFiMode::AccessPoint) ? "1" : "0";
|
||||
|
||||
// AP mode settings
|
||||
map[KEY_AP_SSID] = settings.apSsid;
|
||||
map[KEY_AP_PASSWORD] = settings.apPassword;
|
||||
map[KEY_AP_OPEN_NETWORK] = settings.apOpenNetwork ? "1" : "0";
|
||||
map[KEY_AP_CHANNEL] = std::to_string(settings.apChannel);
|
||||
|
||||
// Web server settings
|
||||
map[KEY_WEBSERVER_ENABLED] = settings.webServerEnabled ? "1" : "0";
|
||||
map[KEY_WEBSERVER_PORT] = std::to_string(settings.webServerPort);
|
||||
|
||||
// Web server auth settings
|
||||
map[KEY_WEBSERVER_AUTH_ENABLED] = settings.webServerAuthEnabled ? "1" : "0";
|
||||
map[KEY_WEBSERVER_USERNAME] = settings.webServerUsername;
|
||||
map[KEY_WEBSERVER_PASSWORD] = settings.webServerPassword;
|
||||
|
||||
// Save to flash storage only (no SD backup - settings sync at boot handles restore)
|
||||
return file::savePropertiesFile(SETTINGS_FILE, map);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user