diff --git a/main/external/mtr/arrival.cpp b/main/external/mtr/arrival.cpp index 0fe398d..fa400eb 100644 --- a/main/external/mtr/arrival.cpp +++ b/main/external/mtr/arrival.cpp @@ -11,8 +11,8 @@ StationArrivalInfo::StationArrivalInfo( const std::string& train_line_code, const std::string& train_station_code ) : _status(UNKNOWN_STATUS) -, _train_line(train_line_code) -, _train_station(train_station_code) { + , _train_line(train_line_code) + , _train_station(train_station_code) { if (!arrival_json) { ESP_LOGE(TAG, "arrival_json is null"); @@ -21,6 +21,8 @@ StationArrivalInfo::StationArrivalInfo( return; } + ESP_LOGD(TAG, "Parsing arrival JSON for %s-%s", train_line_code.c_str(), train_station_code.c_str()); + // Parse status cJSON* status_json = cJSON_GetObjectItem(arrival_json, "status"); if (status_json && cJSON_IsNumber(status_json)) { @@ -30,7 +32,7 @@ StationArrivalInfo::StationArrivalInfo( } } - // TODO: verify the arrival json parsing + ESP_LOGD(TAG, "Status: %d, Message: %s", (int)_status, _message.c_str()); // Parse message (if present) cJSON* message_json = cJSON_GetObjectItem(arrival_json, "message"); diff --git a/main/external/mtr/line_info.cpp b/main/external/mtr/line_info.cpp index 153789b..fe00a43 100644 --- a/main/external/mtr/line_info.cpp +++ b/main/external/mtr/line_info.cpp @@ -17,6 +17,14 @@ LineInfo::LineInfo(cJSON* line_json) { ESP_LOGW(LINE_INFO_TAG, "Missing or invalid 'code' field"); } + // Parse line name + cJSON* name_json = cJSON_GetObjectItem(line_json, "name"); + if (name_json && cJSON_IsString(name_json)) { + _name = name_json->valuestring; + } else { + ESP_LOGW(LINE_INFO_TAG, "Missing or invalid 'name' field"); + } + // Parse line color (note: field is 'line_color' in JSON, not 'color') cJSON* color_json = cJSON_GetObjectItem(line_json, "line_color"); if (color_json && cJSON_IsString(color_json)) { diff --git a/main/external/mtr/line_info.h b/main/external/mtr/line_info.h index a72cad9..d27edc6 100644 --- a/main/external/mtr/line_info.h +++ b/main/external/mtr/line_info.h @@ -20,6 +20,10 @@ public: return _code.c_str(); } // caller does not own the returned char pointers + const char* name() const { + return _name.c_str(); + } + // caller does not own the returned char pointers const char* color() const { return _color.c_str(); } @@ -40,6 +44,7 @@ private: ); std::string _code; + std::string _name; std::string _color; std::vector _stations; }; diff --git a/main/external/mtr/mtr.cpp b/main/external/mtr/mtr.cpp index 8a15f65..62c8024 100644 --- a/main/external/mtr/mtr.cpp +++ b/main/external/mtr/mtr.cpp @@ -8,12 +8,13 @@ #include "cJSON.h" #include "esp_log.h" #include -#include +#include +#include +#include "esp_crt_bundle.h" static const char* TAG = "MTRNextTrainHandler"; // MTR Next Train API endpoint -// Note: This is a placeholder - replace with actual MTR API endpoint static const char* MTR_API_BASE = "https://rt.data.gov.hk/v1/transport/mtr/getSchedule.php"; MTRNextTrainHandler::MTRNextTrainHandler() { @@ -102,13 +103,14 @@ MtrArrivalErrorCode MTRNextTrainHandler::get_next_arrival_info( } // Build API URL - std::ostringstream url; - url << MTR_API_BASE << "?line=" << line_code << "&sta=" << station_code; + std::string url_str = MTR_API_BASE; + url_str += "?line="; + url_str += line_code; + url_str += "&sta="; + url_str += station_code; if (lang == Language::EN) { - url << "&lang=en"; + url_str += "&lang=en"; } - - std::string url_str = url.str(); ESP_LOGI(TAG, "Fetching arrival info from: %s", url_str.c_str()); // Create HTTP client configuration @@ -116,8 +118,7 @@ MtrArrivalErrorCode MTRNextTrainHandler::get_next_arrival_info( http_config.url = url_str.c_str(); http_config.timeout_ms = 10000; http_config.transport_type = HTTP_TRANSPORT_OVER_SSL; - http_config.use_global_ca_store = true; - http_config.skip_cert_common_name_check = false; + http_config.crt_bundle_attach = esp_crt_bundle_attach; // Get HTTP handler and perform request auto http_handler = network_handler->get_http_handler(std::move(http_config)); @@ -146,21 +147,49 @@ MtrArrivalErrorCode MTRNextTrainHandler::get_next_arrival_info( } ESP_LOGI(TAG, "Received %d bytes from MTR API", total_len); - ESP_LOGD(TAG, "Response: %s", buffer); - // Parse JSON response - cJSON* arrival_json = cJSON_Parse(buffer); - free(buffer); + ESP_LOGI(TAG, "Parsing full API response"); + cJSON* root_json = cJSON_Parse(buffer); + delete[] buffer; - if (!arrival_json) { - ESP_LOGE(TAG, "Failed to parse MTR API response"); + if (!root_json) { + const char* error_ptr = cJSON_GetErrorPtr(); + if (error_ptr) { + ESP_LOGE(TAG, "Failed to parse MTR API response at position: %s", error_ptr); + } else { + ESP_LOGE(TAG, "Failed to parse MTR API response - unknown error"); + } return MtrArrivalErrorCode::NO_ARRIVAL_INFO; } - // Create StationArrivalInfo object - out_info = new StationArrivalInfo(mtr_data, arrival_json, line_code, station_code); + cJSON* data_json = cJSON_GetObjectItem(root_json, "data"); + if (!data_json) { + ESP_LOGE(TAG, "Could not find 'data' object in response"); + cJSON_Delete(root_json); + return MtrArrivalErrorCode::NO_ARRIVAL_INFO; + } - cJSON_Delete(arrival_json); + std::string station_key = line_code + "-" + station_code; + cJSON* station_json = cJSON_GetObjectItem(data_json, station_key.c_str()); + if (!station_json) { + ESP_LOGE(TAG, "Could not find station key '%s' in data object", station_key.c_str()); + cJSON_Delete(root_json); + return MtrArrivalErrorCode::NO_ARRIVAL_INFO; + } + + cJSON* status_json = cJSON_GetObjectItem(root_json, "status"); + if (status_json && cJSON_IsNumber(status_json)) { + cJSON_AddItemToObject(station_json, "status", cJSON_Duplicate(status_json, 1)); + } + + cJSON* message_json = cJSON_GetObjectItem(root_json, "message"); + if (message_json && cJSON_IsString(message_json)) { + cJSON_AddItemToObject(station_json, "message", cJSON_Duplicate(message_json, 1)); + } + + out_info = new StationArrivalInfo(mtr_data, station_json, line_code, station_code); + + cJSON_Delete(root_json); ESP_LOGI(TAG, "Successfully retrieved arrival info for %s/%s", line_code.c_str(), station_code.c_str()); return MtrArrivalErrorCode::NONE; diff --git a/main/ui/apps/CMakeLists.txt b/main/ui/apps/CMakeLists.txt index d21ef19..864fae6 100644 --- a/main/ui/apps/CMakeLists.txt +++ b/main/ui/apps/CMakeLists.txt @@ -1,7 +1,7 @@ # Control which apps are included in the build. # Override `ENABLED_APPS` from the top-level CMake command line to change apps. if(NOT DEFINED ENABLED_APPS) - set(ENABLED_APPS "iotdis") + set(ENABLED_APPS "iotdis" "travel") endif() message(STATUS "Enabled apps: ${ENABLED_APPS}") diff --git a/main/ui/apps/registry.cpp b/main/ui/apps/registry.cpp index 528d568..4ff6010 100644 --- a/main/ui/apps/registry.cpp +++ b/main/ui/apps/registry.cpp @@ -1,9 +1,11 @@ #include "ui/apps/registry.h" #include "ui/apps/iotdis/descriptor.h" +#include "ui/apps/travel/descriptor.h" esp_err_t AppRegistry::init(void) { register_app(std::make_unique()); + register_app(std::make_unique()); return ESP_OK; } diff --git a/main/ui/apps/travel/CMakeLists.txt b/main/ui/apps/travel/CMakeLists.txt new file mode 100644 index 0000000..f79a907 --- /dev/null +++ b/main/ui/apps/travel/CMakeLists.txt @@ -0,0 +1,11 @@ +# Explicit list of travel app sources +list(APPEND SRCS + "${CMAKE_CURRENT_LIST_DIR}/web/web_handlers.cpp" + "${CMAKE_CURRENT_LIST_DIR}/descriptor.cpp" + "${CMAKE_CURRENT_LIST_DIR}/settings/settings_handler.cpp" + "${CMAKE_CURRENT_LIST_DIR}/app.cpp" + "${CMAKE_CURRENT_LIST_DIR}/ui/settings_handler.cpp" + "${CMAKE_CURRENT_LIST_DIR}/ui/settings.cpp" + "${CMAKE_CURRENT_LIST_DIR}/ui/main_handler.cpp" + "${CMAKE_CURRENT_LIST_DIR}/ui/main.cpp" +) diff --git a/main/ui/apps/travel/app.cpp b/main/ui/apps/travel/app.cpp new file mode 100644 index 0000000..a11a537 --- /dev/null +++ b/main/ui/apps/travel/app.cpp @@ -0,0 +1,131 @@ +#include "ui/apps/travel/app.h" +#include "ui/apps/travel/ui/main_handler.h" +#include "ui/apps/travel/ui/settings_handler.h" +#include "common/system_context.h" +#include "esp_log.h" + +static const char* TAG = "TravelApp"; + +TravelApp::TravelApp() + : main_ui_handler_(nullptr) + , settings_ui_handler_(nullptr) + , current_page_(Page::MAIN) + , setting_handler_(nullptr) + , network_handler_(nullptr) + , interaction_handler_(nullptr) { + setting_handler_ = std::make_unique( + std::make_unique(TravelApp::NVS_NAMESPACE) + ); +} + +TravelApp::~TravelApp() { } + +esp_err_t TravelApp::init(lv_obj_t* container, InteractionHandler* interaction_handler) { + ESP_LOGI(TAG, "Initializing Travel app"); + + container_ = container; + interaction_handler_ = interaction_handler; + + // Initialize storage + setting_handler_->init(nullptr); + + // Load saved settings + setting_handler_->load_settings(); + + // Get network handler from system context + network_handler_ = SystemContext::instance().get_network_handler(); + + // Create main UI handler + main_ui_handler_ = std::make_unique(); + main_ui_handler_->init(container, interaction_handler_, setting_handler_.get(), network_handler_); + + // Register settings button callback + main_ui_handler_->register_on_settings_button_clicked(on_settings_button_clicked_static, this); + + current_page_ = Page::MAIN; + + return ESP_OK; +} + +esp_err_t TravelApp::deinit() { + ESP_LOGI(TAG, "Deinitializing Travel app"); + + // Clean up UI handlers + if (settings_ui_handler_) { + settings_ui_handler_->deinit(); + settings_ui_handler_.reset(); + } + + if (main_ui_handler_) { + main_ui_handler_->deinit(); + main_ui_handler_.reset(); + } + + return ESP_OK; +} + +std::string TravelApp::get_name() const { + return "Travel"; +} + +bool TravelApp::on_back_button_pressed() { + // If on settings page, go back to main page + if (current_page_ == Page::SETTINGS) { + // Clean up settings handler + if (settings_ui_handler_) { + settings_ui_handler_->deinit(); + settings_ui_handler_.reset(); + } + + // Reload settings in case they were updated + setting_handler_->load_settings(); + + // Recreate main UI handler with updated settings + if (!main_ui_handler_) { + main_ui_handler_ = std::make_unique(); + main_ui_handler_->init(container_, interaction_handler_, setting_handler_.get(), network_handler_); + main_ui_handler_->register_on_settings_button_clicked(on_settings_button_clicked_static, this); + } + + current_page_ = Page::MAIN; + return true; + } + + // Let system handle back (return to app icons) + return false; +} + +void TravelApp::set_network_handler(NetworkHandler* network_handler) { + network_handler_ = network_handler; +} + +// ============================================================================ +// Private Methods +// ============================================================================ + +void TravelApp::show_settings_page() { + ESP_LOGI(TAG, "Showing settings page"); + + // Hide main UI handler + if (main_ui_handler_) { + main_ui_handler_->deinit(); + main_ui_handler_.reset(); + } + + // Create settings UI handler + settings_ui_handler_ = std::make_unique(); + settings_ui_handler_->init(container_, interaction_handler_, setting_handler_.get(), network_handler_); + + current_page_ = Page::SETTINGS; +} + +// ============================================================================ +// Static Callbacks +// ============================================================================ + +void TravelApp::on_settings_button_clicked_static(void* user_data) { + TravelApp* app = static_cast(user_data); + if (app) { + app->show_settings_page(); + } +} diff --git a/main/ui/apps/travel/app.h b/main/ui/apps/travel/app.h new file mode 100644 index 0000000..1a78be7 --- /dev/null +++ b/main/ui/apps/travel/app.h @@ -0,0 +1,72 @@ +#pragma once + +#include "ui/apps/app.h" +#include "ui/apps/travel/settings/settings_handler.h" +#include "ui/apps/travel/ui/main_handler.h" +#include "ui/apps/travel/ui/settings_handler.h" +#include "io/nvs_handler.h" +#include "network/network.h" +#include +#include + +// Forward declarations +namespace travel { +class MainUIHandler; +class SettingsUIHandler; +class SettingHandler; +} + +/** + * @brief Travel App - MTR Station Arrival Time Display + * + * Displays estimated arrival times for configured MTR routes. + * Features: + * - Support for all MTR lines from assets + * - Save up to 5 (station, destination) route pairs + * - Poll every 30 seconds (configurable 10-120s) + * - Traditional Chinese by default + * - E-ink optimized (no animations, static layout) + * - Web-based configuration via QR code + */ +class TravelApp : public UIApp { +public: + TravelApp(); + ~TravelApp() override; + + esp_err_t init(lv_obj_t* container, InteractionHandler* interaction_handler) override; + esp_err_t deinit(void) override; + std::string get_name(void) const override; + bool on_back_button_pressed(void) override; + + // Set network handler for API calls + void set_network_handler(NetworkHandler* network_handler); + +private: + // UI handlers + std::unique_ptr main_ui_handler_; + std::unique_ptr settings_ui_handler_; + + // Current page tracking + enum class Page { + MAIN, + SETTINGS + }; + Page current_page_; + + // Settings handler (shared across handlers) + std::unique_ptr setting_handler_; + + // Network handler (not owned, set externally) + NetworkHandler* network_handler_; + + // Interaction handler (not owned) + InteractionHandler* interaction_handler_; + + static constexpr const char* NVS_NAMESPACE = "travel_app"; + + // Private methods + void show_settings_page(); + + // UI callback forwarders + static void on_settings_button_clicked_static(void* user_data); +}; diff --git a/main/ui/apps/travel/descriptor.cpp b/main/ui/apps/travel/descriptor.cpp new file mode 100644 index 0000000..1aa9f08 --- /dev/null +++ b/main/ui/apps/travel/descriptor.cpp @@ -0,0 +1,12 @@ +#include "ui/apps/travel/descriptor.h" +#include "ui/apps/travel/app.h" + +TravelDescriptor::TravelDescriptor() + : AppDescriptor("Travel", std::make_unique()) { } + +void TravelDescriptor::draw_icon(lv_obj_t* parent) { + // Draw train icon using LVGL symbol + lv_obj_t* icon = lv_label_create(parent); + lv_label_set_text(icon, LV_SYMBOL_DRIVE); // Using drive symbol as train + lv_obj_center(icon); +} diff --git a/main/ui/apps/travel/descriptor.h b/main/ui/apps/travel/descriptor.h new file mode 100644 index 0000000..6ddc55c --- /dev/null +++ b/main/ui/apps/travel/descriptor.h @@ -0,0 +1,14 @@ +#pragma once + +#include "ui/apps/app.h" + +// Forward declaration +class TravelApp; + +class TravelDescriptor : public AppDescriptor { +public: + TravelDescriptor(); + ~TravelDescriptor() override = default; + + void draw_icon(lv_obj_t* parent) override; +}; diff --git a/main/ui/apps/travel/settings/settings_handler.cpp b/main/ui/apps/travel/settings/settings_handler.cpp new file mode 100644 index 0000000..e4d2632 --- /dev/null +++ b/main/ui/apps/travel/settings/settings_handler.cpp @@ -0,0 +1,170 @@ +#include "ui/apps/travel/settings/settings_handler.h" +#include "cJSON.h" +#include "esp_log.h" + +static const char* TAG = "TravelSettings"; + +namespace travel { + +SettingHandler::SettingHandler(std::unique_ptr storage) + : routes_() + , polling_interval_sec_(DEFAULT_POLLING_INTERVAL) + , storage_(std::move(storage)) { +} + +esp_err_t SettingHandler::init(const EventGroupHandle_t& system_event_group) { + storage_->init(system_event_group); + return ESP_OK; +} + +void SettingHandler::load_settings() { + // Load polling interval + std::string poll_str = storage_->get(NVS_KEY_POLLING); + if (!poll_str.empty()) { + polling_interval_sec_ = std::stoul(poll_str); + if (polling_interval_sec_ < MIN_POLLING_INTERVAL || + polling_interval_sec_ > MAX_POLLING_INTERVAL) { + polling_interval_sec_ = DEFAULT_POLLING_INTERVAL; + } + } + + // Load routes + std::string routes_json = storage_->get(NVS_KEY_ROUTES); + if (!routes_json.empty()) { + routes_from_json(routes_json); + } + + ESP_LOGI(TAG, "Loaded %d routes, polling interval: %d seconds", + static_cast(routes_.size()), static_cast(polling_interval_sec_)); +} + +void SettingHandler::save_settings() { + // Save polling interval + storage_->put(NVS_KEY_POLLING, std::to_string(polling_interval_sec_)); + + // Save routes + std::string routes_json = routes_to_json(); + storage_->put(NVS_KEY_ROUTES, routes_json); + + ESP_LOGI(TAG, "Saved %d routes, polling interval: %d seconds", + static_cast(routes_.size()), static_cast(polling_interval_sec_)); +} + +void SettingHandler::add_route(const RoutePair& route) { + if (routes_.size() >= MAX_ROUTES) { + ESP_LOGW(TAG, "Maximum number of routes reached (%d)", static_cast(MAX_ROUTES)); + return; + } + + // Check for duplicates + for (const auto& existing : routes_) { + if (existing == route) { + ESP_LOGW(TAG, "Route already exists"); + return; + } + } + + routes_.push_back(route); + ESP_LOGI(TAG, "Added route: %s -> %s", route.station_name.c_str(), route.dest_name.c_str()); +} + +void SettingHandler::remove_route(size_t index) { + if (index < routes_.size()) { + ESP_LOGI(TAG, "Removing route at index %d", static_cast(index)); + routes_.erase(routes_.begin() + index); + } +} + +void SettingHandler::clear_routes() { + routes_.clear(); + ESP_LOGI(TAG, "Cleared all routes"); +} + +void SettingHandler::set_polling_interval(uint32_t seconds) { + if (seconds < MIN_POLLING_INTERVAL) { + seconds = MIN_POLLING_INTERVAL; + } else if (seconds > MAX_POLLING_INTERVAL) { + seconds = MAX_POLLING_INTERVAL; + } + polling_interval_sec_ = seconds; + ESP_LOGI(TAG, "Set polling interval to %d seconds", static_cast(seconds)); +} + +std::string SettingHandler::routes_to_json() const { + cJSON* root = cJSON_CreateArray(); + + for (const auto& route : routes_) { + cJSON* route_obj = cJSON_CreateObject(); + cJSON_AddStringToObject(route_obj, "line_code", route.line_code.c_str()); + cJSON_AddStringToObject(route_obj, "line_name", route.line_name.c_str()); + cJSON_AddStringToObject(route_obj, "line_color", route.line_color.c_str()); + cJSON_AddStringToObject(route_obj, "station_code", route.station_code.c_str()); + cJSON_AddStringToObject(route_obj, "station_name", route.station_name.c_str()); + cJSON_AddStringToObject(route_obj, "dest_code", route.dest_code.c_str()); + cJSON_AddStringToObject(route_obj, "dest_name", route.dest_name.c_str()); + cJSON_AddItemToArray(root, route_obj); + } + + char* json_str = cJSON_PrintUnformatted(root); + std::string result(json_str ? json_str : "[]"); + if (json_str) { + free(json_str); + } + cJSON_Delete(root); + + return result; +} + +void SettingHandler::routes_from_json(const std::string& json) { + routes_.clear(); + + cJSON* root = cJSON_Parse(json.c_str()); + if (!root || !cJSON_IsArray(root)) { + ESP_LOGE(TAG, "Failed to parse routes JSON"); + if (root) { + cJSON_Delete(root); + } + return; + } + + int array_size = cJSON_GetArraySize(root); + for (int i = 0; i < array_size && i < static_cast(MAX_ROUTES); i++) { + cJSON* route_obj = cJSON_GetArrayItem(root, i); + if (!route_obj || !cJSON_IsObject(route_obj)) { + continue; + } + + RoutePair route; + cJSON* item; + + item = cJSON_GetObjectItem(route_obj, "line_code"); + if (item && cJSON_IsString(item)) route.line_code = item->valuestring; + + item = cJSON_GetObjectItem(route_obj, "line_name"); + if (item && cJSON_IsString(item)) route.line_name = item->valuestring; + + item = cJSON_GetObjectItem(route_obj, "line_color"); + if (item && cJSON_IsString(item)) route.line_color = item->valuestring; + + item = cJSON_GetObjectItem(route_obj, "station_code"); + if (item && cJSON_IsString(item)) route.station_code = item->valuestring; + + item = cJSON_GetObjectItem(route_obj, "station_name"); + if (item && cJSON_IsString(item)) route.station_name = item->valuestring; + + item = cJSON_GetObjectItem(route_obj, "dest_code"); + if (item && cJSON_IsString(item)) route.dest_code = item->valuestring; + + item = cJSON_GetObjectItem(route_obj, "dest_name"); + if (item && cJSON_IsString(item)) route.dest_name = item->valuestring; + + if (!route.line_code.empty() && !route.station_code.empty() && !route.dest_code.empty()) { + routes_.push_back(route); + } + } + + cJSON_Delete(root); + ESP_LOGI(TAG, "Loaded %d routes from JSON", static_cast(routes_.size())); +} + +} // namespace travel diff --git a/main/ui/apps/travel/settings/settings_handler.h b/main/ui/apps/travel/settings/settings_handler.h new file mode 100644 index 0000000..7637f6a --- /dev/null +++ b/main/ui/apps/travel/settings/settings_handler.h @@ -0,0 +1,58 @@ +#pragma once + +#include "freertos/FreeRTOS.h" +#include +#include +#include +#include "io/nvs_handler.h" +#include "ui/apps/travel/types.h" + +namespace travel { + +/** + * @brief Settings handler for Travel app + * + * Manages NVS persistence of route pairs and polling interval. + */ +class SettingHandler { +public: + explicit SettingHandler(std::unique_ptr storage); + ~SettingHandler() = default; + + esp_err_t init(const EventGroupHandle_t& system_event_group); + + void load_settings(); + void save_settings(); + + bool is_configured() const { return !routes_.empty(); } + + // Route management + void add_route(const RoutePair& route); + void remove_route(size_t index); + void clear_routes(); + const std::vector& get_routes() const { return routes_; } + size_t get_route_count() const { return routes_.size(); } + + // Polling interval (seconds) + uint32_t get_polling_interval() const { return polling_interval_sec_; } + void set_polling_interval(uint32_t seconds); + + static constexpr size_t MAX_ROUTES = 5; + static constexpr uint32_t DEFAULT_POLLING_INTERVAL = 30; + static constexpr uint32_t MIN_POLLING_INTERVAL = 10; + static constexpr uint32_t MAX_POLLING_INTERVAL = 120; + +private: + static constexpr const char* NVS_KEY_ROUTES = "routes"; + static constexpr const char* NVS_KEY_POLLING = "poll_interval"; + + std::vector routes_; + uint32_t polling_interval_sec_ = DEFAULT_POLLING_INTERVAL; + std::unique_ptr storage_; + + // JSON serialization helpers + std::string routes_to_json() const; + void routes_from_json(const std::string& json); +}; + +} // namespace travel diff --git a/main/ui/apps/travel/types.h b/main/ui/apps/travel/types.h new file mode 100644 index 0000000..122a07d --- /dev/null +++ b/main/ui/apps/travel/types.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +namespace travel { + +/** + * @brief Structure representing a monitored route (station -> destination pair) + */ +struct RoutePair { + std::string line_code; // Line code (e.g., "ISL", "TWL") + std::string line_name; // Line name in Traditional Chinese (e.g., "港島綫") + std::string line_color; // Hex color code (e.g., "#007DC5") + std::string station_code; // Station code (e.g., "CEN") + std::string station_name; // Station name in TC (e.g., "中環") + std::string dest_code; // Destination station code (e.g., "CHW") + std::string dest_name; // Destination station name in TC (e.g., "柴灣") + + bool operator==(const RoutePair& other) const { + return line_code == other.line_code && + station_code == other.station_code && + dest_code == other.dest_code; + } +}; + +/** + * @brief Structure representing a single arrival display entry + */ +struct ArrivalDisplay { + std::string arrival_time; // Formatted arrival time (e.g., "2分鐘", "14:32") + std::string destination; // Destination station name + std::string platform; // Platform number if available +}; + +/** + * @brief Structure representing all arrival data for a route + */ +struct RouteArrivalData { + RoutePair route; + std::vector arrivals; // List of upcoming trains to destination + bool is_valid = false; + std::string error_message; +}; + +} // namespace travel diff --git a/main/ui/apps/travel/ui/main.cpp b/main/ui/apps/travel/ui/main.cpp new file mode 100644 index 0000000..4f1a7d5 --- /dev/null +++ b/main/ui/apps/travel/ui/main.cpp @@ -0,0 +1,276 @@ +#include "ui/apps/travel/ui/main.h" +#include "display/lvgl_handler.h" +#include "esp_log.h" + +static const char* TAG = "TravelMainUI"; + +namespace travel { + +MainUI::MainUI() = default; + +MainUI::~MainUI() { + deinit(); +} + +esp_err_t MainUI::init(lv_obj_t* parent) { + if (!parent) { + ESP_LOGE(TAG, "Parent is null"); + return ESP_ERR_INVALID_ARG; + } + + parent_ = parent; + + if (!lvgl_port_lock(pdMS_TO_TICKS(1000))) { + ESP_LOGE(TAG, "Failed to acquire LVGL lock"); + return ESP_ERR_TIMEOUT; + } + + // Create main container + container_ = lv_obj_create(parent_); + lv_obj_set_size(container_, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(container_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(container_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_all(container_, 5, 0); + lv_obj_set_style_bg_opa(container_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(container_, 0, 0); + // Disable animations and scrolling for e-ink + lv_obj_set_style_anim_time(container_, 0, 0); + lv_obj_clear_flag(container_, LV_OBJ_FLAG_SCROLLABLE); + + create_header_(); + create_route_displays_(); + + // Message label for errors/empty state + msg_label_ = lv_label_create(container_); + lv_obj_set_width(msg_label_, LV_PCT(100)); + lv_label_set_text(msg_label_, ""); + lv_obj_set_style_text_font(msg_label_, &lv_font_montserrat_14, 0); + lv_obj_add_flag(msg_label_, LV_OBJ_FLAG_HIDDEN); + + // Refresh time label at bottom + refresh_time_label_ = lv_label_create(container_); + lv_obj_set_width(refresh_time_label_, LV_PCT(100)); + lv_label_set_text(refresh_time_label_, ""); + lv_obj_set_style_text_font(refresh_time_label_, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(refresh_time_label_, lv_color_hex(0x808080), 0); + + lvgl_port_unlock(); + + return ESP_OK; +} + +esp_err_t MainUI::deinit() { + if (!lvgl_port_lock(pdMS_TO_TICKS(1000))) { + ESP_LOGE(TAG, "Failed to acquire LVGL lock"); + return ESP_ERR_TIMEOUT; + } + + if (container_) { + lv_obj_del(container_); + container_ = nullptr; + } + + // Reset all pointers + header_label_ = nullptr; + settings_btn_ = nullptr; + refresh_time_label_ = nullptr; + msg_label_ = nullptr; + for (auto& display : route_displays_) { + display.container = nullptr; + display.header = nullptr; + for (int i = 0; i < MAX_ARRIVALS_PER_ROUTE; i++) { + display.arrival_labels[i] = nullptr; + } + } + + lvgl_port_unlock(); + parent_ = nullptr; + + return ESP_OK; +} + +void MainUI::create_header_() { + // Header container + lv_obj_t* header = lv_obj_create(container_); + lv_obj_set_size(header, LV_PCT(100), 35); + lv_obj_set_flex_flow(header, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(header, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_hor(header, 5, 0); + lv_obj_set_style_bg_opa(header, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(header, 0, 0); + lv_obj_set_style_border_width(header, 1, 0); + lv_obj_set_style_border_side(header, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_border_color(header, lv_color_hex(0x808080), 0); + lv_obj_set_style_anim_time(header, 0, 0); + lv_obj_clear_flag(header, LV_OBJ_FLAG_SCROLLABLE); + + // Title label + header_label_ = lv_label_create(header); + lv_label_set_text(header_label_, "MTR到站時間"); + lv_obj_set_style_text_font(header_label_, &lv_font_montserrat_14, 0); + + // Settings button + settings_btn_ = lv_btn_create(header); + lv_obj_set_size(settings_btn_, 30, 30); + lv_obj_t* btn_label = lv_label_create(settings_btn_); + lv_label_set_text(btn_label, LV_SYMBOL_SETTINGS); + lv_obj_center(btn_label); + lv_obj_set_style_anim_time(settings_btn_, 0, 0); +} + +void MainUI::create_route_displays_() { + for (int i = 0; i < MAX_DISPLAY_ROUTES; i++) { + RouteDisplay& display = route_displays_[i]; + + // Container for each route + display.container = lv_obj_create(container_); + lv_obj_set_size(display.container, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(display.container, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(display.container, 3, 0); + lv_obj_set_style_bg_opa(display.container, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(display.container, 0, 0); + lv_obj_set_style_border_width(display.container, 1, 0); + lv_obj_set_style_border_side(display.container, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_border_color(display.container, lv_color_hex(0xC0C0C0), 0); + lv_obj_set_style_anim_time(display.container, 0, 0); + lv_obj_clear_flag(display.container, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(display.container, LV_OBJ_FLAG_HIDDEN); // Hidden by default + + // Route header (station -> destination with line color) + display.header = lv_label_create(display.container); + lv_obj_set_width(display.header, LV_PCT(100)); + lv_label_set_text(display.header, ""); + lv_obj_set_style_text_font(display.header, &lv_font_montserrat_14, 0); + + // Arrival labels (up to 3 per route) + for (int j = 0; j < MAX_ARRIVALS_PER_ROUTE; j++) { + display.arrival_labels[j] = lv_label_create(display.container); + lv_obj_set_width(display.arrival_labels[j], LV_PCT(100)); + lv_label_set_text(display.arrival_labels[j], ""); + lv_obj_set_style_text_font(display.arrival_labels[j], &lv_font_montserrat_14, 0); + lv_obj_set_style_pad_left(display.arrival_labels[j], 10, 0); + } + } +} + +void MainUI::update_arrivals(const std::vector& arrival_data) { + if (!lvgl_port_lock(pdMS_TO_TICKS(1000))) { + ESP_LOGE(TAG, "Failed to acquire LVGL lock"); + return; + } + + // Hide message label + lv_obj_add_flag(msg_label_, LV_OBJ_FLAG_HIDDEN); + + // Update each route display + for (int i = 0; i < MAX_DISPLAY_ROUTES; i++) { + if (i < static_cast(arrival_data.size())) { + update_route_display_(route_displays_[i], arrival_data[i]); + } else { + // Hide unused route displays + lv_obj_add_flag(route_displays_[i].container, LV_OBJ_FLAG_HIDDEN); + } + } + + lvgl_port_unlock(); +} + +void MainUI::update_route_display_(RouteDisplay& display, const RouteArrivalData& data) { + lv_obj_clear_flag(display.container, LV_OBJ_FLAG_HIDDEN); + + // Update header with line color + std::string header_text = data.route.station_name + " → " + data.route.dest_name; + lv_label_set_text(display.header, header_text.c_str()); + + if (!data.route.line_color.empty()) { + lv_color_t line_color = hex_to_lv_color_(data.route.line_color); + lv_obj_set_style_text_color(display.header, line_color, 0); + } + + // Update arrival labels + for (int i = 0; i < MAX_ARRIVALS_PER_ROUTE; i++) { + if (i < static_cast(data.arrivals.size())) { + const auto& arrival = data.arrivals[i]; + std::string arrival_text = " " + arrival.arrival_time + " 往" + arrival.destination; + lv_label_set_text(display.arrival_labels[i], arrival_text.c_str()); + lv_obj_clear_flag(display.arrival_labels[i], LV_OBJ_FLAG_HIDDEN); + } else { + lv_label_set_text(display.arrival_labels[i], ""); + lv_obj_add_flag(display.arrival_labels[i], LV_OBJ_FLAG_HIDDEN); + } + } + + // Show error if any + if (!data.is_valid && !data.error_message.empty()) { + lv_label_set_text(display.arrival_labels[0], (" 錯誤: " + data.error_message).c_str()); + lv_obj_clear_flag(display.arrival_labels[0], LV_OBJ_FLAG_HIDDEN); + } +} + +void MainUI::update_last_refresh_time(const std::string& time_str) { + if (!lvgl_port_lock(pdMS_TO_TICKS(1000))) { + ESP_LOGE(TAG, "Failed to acquire LVGL lock"); + return; + } + + std::string full_text = "更新: " + time_str; + lv_label_set_text(refresh_time_label_, full_text.c_str()); + + lvgl_port_unlock(); +} + +void MainUI::show_no_routes_message() { + if (!lvgl_port_lock(pdMS_TO_TICKS(1000))) { + ESP_LOGE(TAG, "Failed to acquire LVGL lock"); + return; + } + + // Hide all route displays + for (auto& display : route_displays_) { + lv_obj_add_flag(display.container, LV_OBJ_FLAG_HIDDEN); + } + + // Show message + lv_label_set_text(msg_label_, "請按右上角設定按鈕添加路線"); + lv_obj_clear_flag(msg_label_, LV_OBJ_FLAG_HIDDEN); + + lvgl_port_unlock(); +} + +void MainUI::show_error_message(const std::string& message) { + if (!lvgl_port_lock(pdMS_TO_TICKS(1000))) { + ESP_LOGE(TAG, "Failed to acquire LVGL lock"); + return; + } + + // Hide all route displays + for (auto& display : route_displays_) { + lv_obj_add_flag(display.container, LV_OBJ_FLAG_HIDDEN); + } + + // Show error message + lv_label_set_text(msg_label_, ("錯誤: " + message).c_str()); + lv_obj_clear_flag(msg_label_, LV_OBJ_FLAG_HIDDEN); + + lvgl_port_unlock(); +} + +void MainUI::register_settings_button_callback(lv_event_cb_t cb, void* user_data) { + if (settings_btn_) { + lv_obj_add_event_cb(settings_btn_, cb, LV_EVENT_CLICKED, user_data); + } +} + +lv_color_t MainUI::hex_to_lv_color_(const std::string& hex_color) { + if (hex_color.length() < 7 || hex_color[0] != '#') { + return lv_color_black(); + } + + unsigned int r = std::stoi(hex_color.substr(1, 2), nullptr, 16); + unsigned int g = std::stoi(hex_color.substr(3, 2), nullptr, 16); + unsigned int b = std::stoi(hex_color.substr(5, 2), nullptr, 16); + + return lv_color_make(r, g, b); +} + +} // namespace travel diff --git a/main/ui/apps/travel/ui/main.h b/main/ui/apps/travel/ui/main.h new file mode 100644 index 0000000..cd879a4 --- /dev/null +++ b/main/ui/apps/travel/ui/main.h @@ -0,0 +1,59 @@ +#pragma once + +#include "lvgl.h" +#include "esp_err.h" +#include "ui/apps/travel/types.h" +#include +#include + +namespace travel { + +/** + * @brief Main UI for Travel app - displays train arrivals + * + * E-ink optimized: no animations, static layout, no scrolling + */ +class MainUI { +public: + MainUI(); + ~MainUI(); + + esp_err_t init(lv_obj_t* parent); + esp_err_t deinit(); + + // Update display with arrival data + void update_arrivals(const std::vector& arrival_data); + void update_last_refresh_time(const std::string& time_str); + void show_no_routes_message(); + void show_error_message(const std::string& message); + + // Register settings button callback + void register_settings_button_callback(lv_event_cb_t cb, void* user_data); + +private: + lv_obj_t* parent_ = nullptr; + lv_obj_t* container_ = nullptr; + lv_obj_t* header_label_ = nullptr; + lv_obj_t* settings_btn_ = nullptr; + lv_obj_t* refresh_time_label_ = nullptr; + lv_obj_t* msg_label_ = nullptr; + + // Route display containers (up to MAX_ROUTES) + struct RouteDisplay { + lv_obj_t* container = nullptr; + lv_obj_t* header = nullptr; + lv_obj_t* arrival_labels[3] = {nullptr, nullptr, nullptr}; // Show up to 3 arrivals per route + }; + RouteDisplay route_displays_[5]; + + static constexpr int MAX_DISPLAY_ROUTES = 5; + static constexpr int MAX_ARRIVALS_PER_ROUTE = 3; + + void create_header_(); + void create_route_displays_(); + void clear_route_display_(RouteDisplay& display); + void update_route_display_(RouteDisplay& display, const RouteArrivalData& data); + lv_color_t hex_to_lv_color_(const std::string& hex_color); +}; + +} // namespace travel diff --git a/main/ui/apps/travel/ui/main_handler.cpp b/main/ui/apps/travel/ui/main_handler.cpp new file mode 100644 index 0000000..3cfde0d --- /dev/null +++ b/main/ui/apps/travel/ui/main_handler.cpp @@ -0,0 +1,249 @@ +#include "ui/apps/travel/ui/main_handler.h" +#include "display/lvgl_handler.h" +#include "external/mtr/arrival.h" +#include "esp_log.h" +#include +#include +#include + +static const char* TAG = "TravelMainHandler"; + +namespace travel { + +MainUIHandler::MainUIHandler() + : main_ui_(std::make_unique()) + , mtr_handler_(std::make_unique()) { + refresh_mutex_ = xSemaphoreCreateMutex(); +} + +MainUIHandler::~MainUIHandler() { + deinit(); + if (refresh_mutex_) { + vSemaphoreDelete(refresh_mutex_); + } +} + +esp_err_t MainUIHandler::init( + lv_obj_t* parent, + InteractionHandler* interaction_handler, + SettingHandler* setting_handler, + NetworkHandler* network_handler +) { + ESP_LOGI(TAG, "Initializing main UI handler"); + + setting_handler_ = setting_handler; + network_handler_ = network_handler; + + // Initialize UI + esp_err_t err = main_ui_->init(parent); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to init main UI"); + return err; + } + + // Register settings button callback + main_ui_->register_settings_button_callback(on_settings_button_clicked_static_, this); + + // Check if configured + if (!setting_handler_->is_configured()) { + main_ui_->show_no_routes_message(); + return ESP_OK; + } + + // Start polling task + polling_running_ = true; + BaseType_t task_created = xTaskCreate( + polling_task_, + "travel_poll", + 8192, + this, + 5, + &polling_task_handle_ + ); + + if (task_created != pdPASS) { + ESP_LOGE(TAG, "Failed to create polling task"); + polling_running_ = false; + return ESP_ERR_NO_MEM; + } + + // Do initial refresh + fetch_and_update_arrivals_(); + + return ESP_OK; +} + +esp_err_t MainUIHandler::deinit() { + ESP_LOGI(TAG, "Deinitializing main UI handler"); + + // Stop polling task + if (polling_task_handle_) { + polling_running_ = false; + // Wait for task to finish + vTaskDelay(pdMS_TO_TICKS(100)); + polling_task_handle_ = nullptr; + } + + // Deinit UI + if (main_ui_) { + main_ui_->deinit(); + } + + return ESP_OK; +} + +void MainUIHandler::register_on_settings_button_clicked(SettingsButtonCallback cb, void* user_data) { + on_settings_callback_ = cb; + settings_callback_user_data_ = user_data; +} + +void MainUIHandler::force_refresh() { + if (xSemaphoreTake(refresh_mutex_, pdMS_TO_TICKS(1000)) == pdTRUE) { + fetch_and_update_arrivals_(); + xSemaphoreGive(refresh_mutex_); + } +} + +void MainUIHandler::polling_task_(void* param) { + MainUIHandler* handler = static_cast(param); + + while (handler->polling_running_) { + uint32_t interval_ms = handler->setting_handler_->get_polling_interval() * 1000; + + if (xSemaphoreTake(handler->refresh_mutex_, pdMS_TO_TICKS(1000)) == pdTRUE) { + handler->fetch_and_update_arrivals_(); + xSemaphoreGive(handler->refresh_mutex_); + } + + // Delay until next poll + vTaskDelay(pdMS_TO_TICKS(interval_ms)); + } + + vTaskDelete(nullptr); +} + +void MainUIHandler::fetch_and_update_arrivals_() { + if (!network_handler_ || !setting_handler_) { + return; + } + + const auto& routes = setting_handler_->get_routes(); + if (routes.empty()) { + main_ui_->show_no_routes_message(); + return; + } + + std::vector arrival_data; + + for (const auto& route : routes) { + RouteArrivalData data; + data.route = route; + + // Fetch arrival info from MTR API + std::string line_code = route.line_code; + std::string station_code = route.station_code; + StationArrivalInfo* arrival_info = nullptr; + + MtrArrivalErrorCode error = mtr_handler_->get_next_arrival_info( + network_handler_, + line_code, + station_code, + arrival_info, + Language::TC // Traditional Chinese + ); + + if (error == MtrArrivalErrorCode::NONE && arrival_info) { + // Filter arrivals going to our destination + const auto* up_arrivals = arrival_info->up_arrivals(); + const auto* down_arrivals = arrival_info->down_arrivals(); + + // Check both UP and DOWN directions for trains to our destination + auto filter_arrivals = [&](const std::vector* arrivals) { + if (!arrivals) return; + for (const auto& arrival : *arrivals) { + // Check if this train goes to our destination + std::string dest = arrival.destination(); + if (dest.find(route.dest_name) != std::string::npos || + dest.find(route.dest_code) != std::string::npos) { + ArrivalDisplay display; + display.arrival_time = format_arrival_time_(arrival.arrival_time()); + display.destination = dest; + data.arrivals.push_back(display); + } + } + }; + + filter_arrivals(up_arrivals); + filter_arrivals(down_arrivals); + + data.is_valid = true; + + // Clean up + delete arrival_info; + } else { + data.is_valid = false; + switch (error) { + case MtrArrivalErrorCode::LINE_NOT_FOUND: + data.error_message = "路線不存在"; + break; + case MtrArrivalErrorCode::STATION_NOT_FOUND: + data.error_message = "車站不存在"; + break; + case MtrArrivalErrorCode::NO_ARRIVAL_INFO: + data.error_message = "無到站資料"; + break; + default: + data.error_message = "無法連接"; + break; + } + } + + arrival_data.push_back(data); + } + + // Update UI + main_ui_->update_arrivals(arrival_data); + main_ui_->update_last_refresh_time(get_current_time_string_()); +} + +std::string MainUIHandler::format_arrival_time_(const std::string& api_time) { + // API returns time in format like "2024-01-15T14:30:00+08:00" or "2" + // Check if it's a simple minute count + if (api_time.length() <= 2) { + return api_time + "分鐘"; + } + + // Try to parse ISO format time + // Extract time part (HH:MM) + size_t t_pos = api_time.find('T'); + if (t_pos != std::string::npos && api_time.length() > t_pos + 5) { + std::string time_part = api_time.substr(t_pos + 1, 5); + return time_part; + } + + return api_time; +} + +std::string MainUIHandler::get_current_time_string_() { + auto now = std::time(nullptr); + auto tm = *std::localtime(&now); + + char buffer[9]; // HH:MM:SS\0 + strftime(buffer, sizeof(buffer), "%H:%M:%S", &tm); + return std::string(buffer); +} + +void MainUIHandler::on_settings_button_clicked_static_(lv_event_t* e) { + MainUIHandler* handler = static_cast(lv_event_get_user_data(e)); + if (handler) { + handler->on_settings_button_clicked_(); + } +} + +void MainUIHandler::on_settings_button_clicked_() { + if (on_settings_callback_) { + on_settings_callback_(settings_callback_user_data_); + } +} + +} // namespace travel diff --git a/main/ui/apps/travel/ui/main_handler.h b/main/ui/apps/travel/ui/main_handler.h new file mode 100644 index 0000000..1818f66 --- /dev/null +++ b/main/ui/apps/travel/ui/main_handler.h @@ -0,0 +1,69 @@ +#pragma once + +#include "ui/apps/travel/ui/main.h" +#include "ui/apps/travel/settings/settings_handler.h" +#include "ui/interaction_handler.h" +#include "external/mtr/mtr.h" +#include "network/network.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" +#include "esp_err.h" +#include +#include +#include + +namespace travel { + +/** + * @brief Main UI Handler for Travel app + * + * Manages the MainUI instance, polling task, and MTR API interactions. + * Runs a background task to periodically fetch arrival data. + */ +class MainUIHandler { +public: + // Callback type for settings button + using SettingsButtonCallback = void (*)(void* user_data); + + MainUIHandler(); + ~MainUIHandler(); + + esp_err_t init( + lv_obj_t* parent, + InteractionHandler* interaction_handler, + SettingHandler* setting_handler, + NetworkHandler* network_handler + ); + esp_err_t deinit(); + + void register_on_settings_button_clicked(SettingsButtonCallback cb, void* user_data); + void force_refresh(); + +private: + static void polling_task_(void* param); + static void on_settings_button_clicked_static_(lv_event_t* e); + + void on_settings_button_clicked_(); + void fetch_and_update_arrivals_(); + std::string format_arrival_time_(const std::string& api_time); + std::string get_current_time_string_(); + + std::unique_ptr main_ui_; + SettingHandler* setting_handler_ = nullptr; + NetworkHandler* network_handler_ = nullptr; + std::unique_ptr mtr_handler_; + + // Polling task + TaskHandle_t polling_task_handle_ = nullptr; + std::atomic polling_running_{false}; + SemaphoreHandle_t refresh_mutex_ = nullptr; + + // Callback for settings button + SettingsButtonCallback on_settings_callback_ = nullptr; + void* settings_callback_user_data_ = nullptr; + + static constexpr uint32_t LVGL_LOCK_TIMEOUT_MS = 4000; +}; + +} // namespace travel diff --git a/main/ui/apps/travel/ui/settings.cpp b/main/ui/apps/travel/ui/settings.cpp new file mode 100644 index 0000000..dcbde16 --- /dev/null +++ b/main/ui/apps/travel/ui/settings.cpp @@ -0,0 +1,150 @@ +#include "ui/apps/travel/ui/settings.h" +#include "display/lvgl_handler.h" +#include "esp_log.h" + +static const char* TAG = "TravelSettingsUI"; + +namespace travel { + +SettingsUI::SettingsUI() = default; + +SettingsUI::~SettingsUI() { + deinit(); +} + +esp_err_t SettingsUI::init(lv_obj_t* parent) { + if (!parent) { + ESP_LOGE(TAG, "Parent is null"); + return ESP_ERR_INVALID_ARG; + } + + parent_ = parent; + + if (!lvgl_port_lock(pdMS_TO_TICKS(1000))) { + ESP_LOGE(TAG, "Failed to acquire LVGL lock"); + return ESP_ERR_TIMEOUT; + } + + // Create main container + container_ = lv_obj_create(parent_); + lv_obj_set_size(container_, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(container_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(container_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(container_, 10, 0); + lv_obj_set_style_bg_opa(container_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(container_, 0, 0); + // Disable animations and scrolling for e-ink + lv_obj_set_style_anim_time(container_, 0, 0); + lv_obj_clear_flag(container_, LV_OBJ_FLAG_SCROLLABLE); + + // Title + title_label_ = lv_label_create(container_); + lv_label_set_text(title_label_, "設定路線"); + lv_obj_set_style_text_font(title_label_, &lv_font_montserrat_14, 0); + lv_obj_set_style_pad_bottom(title_label_, 10, 0); + + // QR Code container + lv_obj_t* qr_container = lv_obj_create(container_); + lv_obj_set_size(qr_container, QR_CODE_SIZE + 10, QR_CODE_SIZE + 10); + lv_obj_set_style_bg_color(qr_container, lv_color_white(), 0); + lv_obj_set_style_border_width(qr_container, 2, 0); + lv_obj_set_style_border_color(qr_container, lv_color_black(), 0); + lv_obj_set_style_anim_time(qr_container, 0, 0); + lv_obj_clear_flag(qr_container, LV_OBJ_FLAG_SCROLLABLE); + + // QR Code + qr_code_ = lv_qrcode_create(qr_container); + lv_qrcode_set_size(qr_code_, QR_CODE_SIZE); + lv_qrcode_set_dark_color(qr_code_, lv_color_black()); + lv_qrcode_set_light_color(qr_code_, lv_color_white()); + lv_obj_center(qr_code_); + + // URL label + url_label_ = lv_label_create(container_); + lv_obj_set_width(url_label_, LV_PCT(100)); + lv_label_set_text(url_label_, ""); + lv_obj_set_style_text_font(url_label_, &lv_font_montserrat_14, 0); + lv_label_set_long_mode(url_label_, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_align(url_label_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_pad_top(url_label_, 10, 0); + + // Status message + status_label_ = lv_label_create(container_); + lv_obj_set_width(status_label_, LV_PCT(100)); + lv_label_set_text(status_label_, "正在啟動伺服器..."); + lv_obj_set_style_text_font(status_label_, &lv_font_montserrat_14, 0); + lv_label_set_long_mode(status_label_, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_align(status_label_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_pad_top(status_label_, 15, 0); + + // Instructions + instruction_label_ = lv_label_create(container_); + lv_obj_set_width(instruction_label_, LV_PCT(100)); + lv_label_set_text(instruction_label_, + "請使用手機掃描QR碼或瀏覽器開啟網址\n" + "以設定MTR路線"); + lv_obj_set_style_text_font(instruction_label_, &lv_font_montserrat_14, 0); + lv_label_set_long_mode(instruction_label_, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_align(instruction_label_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(instruction_label_, lv_color_hex(0x606060), 0); + lv_obj_set_style_pad_top(instruction_label_, 15, 0); + + lvgl_port_unlock(); + + return ESP_OK; +} + +esp_err_t SettingsUI::deinit() { + if (!lvgl_port_lock(pdMS_TO_TICKS(1000))) { + ESP_LOGE(TAG, "Failed to acquire LVGL lock"); + return ESP_ERR_TIMEOUT; + } + + if (container_) { + lv_obj_del(container_); + container_ = nullptr; + } + + title_label_ = nullptr; + qr_code_ = nullptr; + url_label_ = nullptr; + status_label_ = nullptr; + instruction_label_ = nullptr; + + lvgl_port_unlock(); + parent_ = nullptr; + + return ESP_OK; +} + +void SettingsUI::update_qr_code(const std::string& url) { + if (!lvgl_port_lock(pdMS_TO_TICKS(1000))) { + ESP_LOGE(TAG, "Failed to acquire LVGL lock"); + return; + } + + if (qr_code_) { + lv_qrcode_update(qr_code_, url.c_str(), url.length()); + } + + if (url_label_) { + lv_label_set_text(url_label_, url.c_str()); + } + + lvgl_port_unlock(); +} + +void SettingsUI::update_status_message(const std::string& message) { + if (!lvgl_port_lock(pdMS_TO_TICKS(1000))) { + ESP_LOGE(TAG, "Failed to acquire LVGL lock"); + return; + } + + if (status_label_) { + lv_label_set_text(status_label_, message.c_str()); + } + + lvgl_port_unlock(); +} + +} // namespace travel diff --git a/main/ui/apps/travel/ui/settings.h b/main/ui/apps/travel/ui/settings.h new file mode 100644 index 0000000..ae292bc --- /dev/null +++ b/main/ui/apps/travel/ui/settings.h @@ -0,0 +1,38 @@ +#pragma once + +#include "lvgl.h" +#include "esp_err.h" +#include + +namespace travel { + +/** + * @brief Settings UI for Travel app + * + * Displays QR code for web configuration. + * E-ink optimized: no animations, static layout. + */ +class SettingsUI { +public: + SettingsUI(); + ~SettingsUI(); + + esp_err_t init(lv_obj_t* parent); + esp_err_t deinit(); + + void update_qr_code(const std::string& url); + void update_status_message(const std::string& message); + +private: + lv_obj_t* parent_ = nullptr; + lv_obj_t* container_ = nullptr; + lv_obj_t* title_label_ = nullptr; + lv_obj_t* qr_code_ = nullptr; + lv_obj_t* url_label_ = nullptr; + lv_obj_t* status_label_ = nullptr; + lv_obj_t* instruction_label_ = nullptr; + + static constexpr int QR_CODE_SIZE = 160; +}; + +} // namespace travel diff --git a/main/ui/apps/travel/ui/settings_handler.cpp b/main/ui/apps/travel/ui/settings_handler.cpp new file mode 100644 index 0000000..406c03c --- /dev/null +++ b/main/ui/apps/travel/ui/settings_handler.cpp @@ -0,0 +1,85 @@ +#include "ui/apps/travel/ui/settings_handler.h" +#include "display/lvgl_handler.h" +#include "esp_log.h" + +static const char* TAG = "TravelSettingsHandler"; + +namespace travel { + +SettingsUIHandler::SettingsUIHandler() + : settings_ui_(std::make_unique()) + , web_handler_(nullptr) { +} + +SettingsUIHandler::~SettingsUIHandler() { + deinit(); +} + +esp_err_t SettingsUIHandler::init( + lv_obj_t* parent, + InteractionHandler* interaction_handler, + SettingHandler* setting_handler, + NetworkHandler* network_handler +) { + ESP_LOGI(TAG, "Initializing settings UI handler"); + + setting_handler_ = setting_handler; + network_handler_ = network_handler; + + // Initialize UI + esp_err_t err = settings_ui_->init(parent); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to init settings UI"); + return err; + } + + // Start web server + start_web_server_(); + + return ESP_OK; +} + +esp_err_t SettingsUIHandler::deinit() { + ESP_LOGI(TAG, "Deinitializing settings UI handler"); + + // Stop web server + if (web_handler_) { + web_handler_->stop_web_server(); + web_handler_.reset(); + } + + // Deinit UI + if (settings_ui_) { + settings_ui_->deinit(); + } + + return ESP_OK; +} + +void SettingsUIHandler::start_web_server_() { + if (!setting_handler_ || !network_handler_) { + ESP_LOGE(TAG, "Cannot start web server - missing handlers"); + settings_ui_->update_status_message("設定錯誤"); + return; + } + + // Create web handler + web_handler_ = std::make_unique(setting_handler_, network_handler_); + + // Start server + esp_err_t err = web_handler_->start_web_server(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to start web server: %s", esp_err_to_name(err)); + settings_ui_->update_status_message("無法啟動伺服器"); + return; + } + + // Update QR code with URL + std::string url = web_handler_->get_url(); + settings_ui_->update_qr_code(url); + settings_ui_->update_status_message("伺服器運行中"); + + ESP_LOGI(TAG, "Web server started at %s", url.c_str()); +} + +} // namespace travel diff --git a/main/ui/apps/travel/ui/settings_handler.h b/main/ui/apps/travel/ui/settings_handler.h new file mode 100644 index 0000000..25d71ce --- /dev/null +++ b/main/ui/apps/travel/ui/settings_handler.h @@ -0,0 +1,40 @@ +#pragma once + +#include "ui/apps/travel/ui/settings.h" +#include "ui/apps/travel/settings/settings_handler.h" +#include "ui/apps/travel/web/web_handlers.h" +#include "ui/interaction_handler.h" +#include "network/network.h" +#include "esp_err.h" +#include + +namespace travel { + +/** + * @brief Settings UI Handler for Travel app + * + * Manages the SettingsUI instance, web server, and settings persistence. + */ +class SettingsUIHandler { +public: + SettingsUIHandler(); + ~SettingsUIHandler(); + + esp_err_t init( + lv_obj_t* parent, + InteractionHandler* interaction_handler, + SettingHandler* setting_handler, + NetworkHandler* network_handler + ); + esp_err_t deinit(); + +private: + void start_web_server_(); + + std::unique_ptr settings_ui_; + std::unique_ptr web_handler_; + SettingHandler* setting_handler_ = nullptr; + NetworkHandler* network_handler_ = nullptr; +}; + +} // namespace travel diff --git a/main/ui/apps/travel/web/web_handlers.cpp b/main/ui/apps/travel/web/web_handlers.cpp new file mode 100644 index 0000000..a856e45 --- /dev/null +++ b/main/ui/apps/travel/web/web_handlers.cpp @@ -0,0 +1,718 @@ +#include "ui/apps/travel/web/web_handlers.h" +#include "esp_log.h" +#include "cJSON.h" +#include +#include +#include + +static const char* TAG = "TravelWebHandler"; + +namespace travel { + +WebHandler::WebHandler( + SettingHandler* setting_handler, + NetworkHandler* network_handler +) + : web_server_(std::make_unique()) + , setting_handler_(setting_handler) + , network_handler_(network_handler) + , mtr_handler_(std::make_unique()) + , auth_key_(generate_auth_key_()) { +} + +WebHandler::~WebHandler() { + stop_web_server(); +} + +std::string WebHandler::generate_auth_key_() { + // Generate a random 16-character hex key + std::stringstream ss; + for (int i = 0; i < 8; i++) { + ss << std::hex << std::setw(2) << std::setfill('0') << (esp_random() & 0xFF); + } + return ss.str(); +} + +esp_err_t WebHandler::start_web_server() { + uint16_t port = web_server_->start(auth_key_, WEB_SERVER_PORT); + if (port == 0) { + ESP_LOGE(TAG, "Failed to start web server"); + return ESP_FAIL; + } + + esp_err_t err = register_web_endpoints_(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to register endpoints: %s", esp_err_to_name(err)); + web_server_->stop(); + return err; + } + + ESP_LOGI(TAG, "Web server started on port %d", port); + return ESP_OK; +} + +esp_err_t WebHandler::stop_web_server() { + if (web_server_) { + web_server_->stop(); + } + return ESP_OK; +} + +std::string WebHandler::get_url() const { + std::string ip = get_device_ip(); + if (ip.empty()) { + return ""; + } + return "http://" + ip + ":" + std::to_string(WEB_SERVER_PORT) + "/?auth=" + auth_key_; +} + +std::string WebHandler::get_device_ip() const { + if (!network_handler_) { + return ""; + } + return network_handler_->get_wifi_handler().get_current_ip(); +} + +uint16_t WebHandler::get_port() const { + return WEB_SERVER_PORT; +} + +esp_err_t WebHandler::register_web_endpoints_() { + // Main settings page + httpd_uri_t settings_uri = { + .uri = "/", + .method = HTTP_GET, + .handler = settings_page_handler_, + .user_ctx = this + }; + ESP_ERROR_CHECK(web_server_->register_uri_handler(&settings_uri)); + + // Get MTR lines + httpd_uri_t lines_uri = { + .uri = "/api/lines", + .method = HTTP_GET, + .handler = get_lines_handler_, + .user_ctx = this + }; + ESP_ERROR_CHECK(web_server_->register_uri_handler(&lines_uri)); + + // Get saved routes + httpd_uri_t routes_uri = { + .uri = "/api/routes", + .method = HTTP_GET, + .handler = get_routes_handler_, + .user_ctx = this + }; + ESP_ERROR_CHECK(web_server_->register_uri_handler(&routes_uri)); + + // Add route + httpd_uri_t add_route_uri = { + .uri = "/api/routes", + .method = HTTP_POST, + .handler = add_route_handler_, + .user_ctx = this + }; + ESP_ERROR_CHECK(web_server_->register_uri_handler(&add_route_uri)); + + // Remove route + httpd_uri_t remove_route_uri = { + .uri = "/api/routes", + .method = HTTP_DELETE, + .handler = remove_route_handler_, + .user_ctx = this + }; + ESP_ERROR_CHECK(web_server_->register_uri_handler(&remove_route_uri)); + + // Save settings (polling interval) + httpd_uri_t save_uri = { + .uri = "/api/settings", + .method = HTTP_POST, + .handler = save_settings_handler_, + .user_ctx = this + }; + ESP_ERROR_CHECK(web_server_->register_uri_handler(&save_uri)); + + return ESP_OK; +} + +esp_err_t WebHandler::settings_page_handler_(httpd_req_t* req) { + WebHandler* handler = static_cast(req->user_ctx); + + // Check auth + char auth_param[33] = {0}; + if (httpd_req_get_url_query_str(req, auth_param, sizeof(auth_param)) == ESP_OK) { + char auth_value[33] = {0}; + if (httpd_query_key_value(auth_param, "auth", auth_value, sizeof(auth_value)) == ESP_OK) { + if (handler->auth_key_ != auth_value) { + httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); + return ESP_FAIL; + } + } else { + httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Missing auth"); + return ESP_FAIL; + } + } else { + httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Missing auth"); + return ESP_FAIL; + } + + // HTML page with inline CSS and JavaScript + const char* html = R"html( + + + + + + MTR Travel Settings + + + +

MTR Travel 設定

+ +
+

新增路線

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

已儲存路線

+
+
+ +
+

更新頻率

+
+ +
+ + 30 +
+
+ +
+ +
+ + + + + )html"; + + httpd_resp_set_type(req, "text/html"); + httpd_resp_send(req, html, strlen(html)); + + return ESP_OK; +} + +esp_err_t WebHandler::get_lines_handler_(httpd_req_t* req) { + WebHandler* handler = static_cast(req->user_ctx); + + // Get all lines from MTR handler + std::vector lines = handler->mtr_handler_->get_lines(); + + cJSON* root = cJSON_CreateArray(); + for (const auto& line : lines) { + cJSON* line_obj = cJSON_CreateObject(); + cJSON_AddStringToObject(line_obj, "code", line.code()); + cJSON_AddStringToObject(line_obj, "name", line.name()); + cJSON_AddStringToObject(line_obj, "color", line.color()); + + // Add stations + cJSON* stations_arr = cJSON_CreateArray(); + const auto* stations = line.stations(); + if (stations) { + for (const auto& station : *stations) { + cJSON* station_obj = cJSON_CreateObject(); + cJSON_AddStringToObject(station_obj, "code", station.code()); + cJSON_AddStringToObject(station_obj, "name", station.name()); + cJSON_AddItemToArray(stations_arr, station_obj); + } + } + cJSON_AddItemToObject(line_obj, "stations", stations_arr); + + cJSON_AddItemToArray(root, line_obj); + } + + char* json_str = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json_str ? json_str : "[]", json_str ? strlen(json_str) : 2); + + if (json_str) { + free(json_str); + } + + return ESP_OK; +} + +esp_err_t WebHandler::get_routes_handler_(httpd_req_t* req) { + WebHandler* handler = static_cast(req->user_ctx); + + cJSON* root = cJSON_CreateObject(); + + // Add routes + cJSON* routes_arr = cJSON_CreateArray(); + const auto& routes = handler->setting_handler_->get_routes(); + for (const auto& route : routes) { + cJSON* route_obj = cJSON_CreateObject(); + cJSON_AddStringToObject(route_obj, "line_code", route.line_code.c_str()); + cJSON_AddStringToObject(route_obj, "line_name", route.line_name.c_str()); + cJSON_AddStringToObject(route_obj, "line_color", route.line_color.c_str()); + cJSON_AddStringToObject(route_obj, "station_code", route.station_code.c_str()); + cJSON_AddStringToObject(route_obj, "station_name", route.station_name.c_str()); + cJSON_AddStringToObject(route_obj, "dest_code", route.dest_code.c_str()); + cJSON_AddStringToObject(route_obj, "dest_name", route.dest_name.c_str()); + cJSON_AddItemToArray(routes_arr, route_obj); + } + cJSON_AddItemToObject(root, "routes", routes_arr); + + // Add polling interval + cJSON_AddNumberToObject(root, "polling_interval", handler->setting_handler_->get_polling_interval()); + + char* json_str = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json_str ? json_str : "{}", json_str ? strlen(json_str) : 2); + + if (json_str) { + free(json_str); + } + + return ESP_OK; +} + +esp_err_t WebHandler::add_route_handler_(httpd_req_t* req) { + WebHandler* handler = static_cast(req->user_ctx); + + // Read request body + char buf[512]; + int received = 0; + int remaining = req->content_len; + + std::string body; + while (remaining > 0) { + received = httpd_req_recv(req, buf, std::min(remaining, (int)sizeof(buf) - 1)); + if (received <= 0) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Failed to read request"); + return ESP_FAIL; + } + buf[received] = '\0'; + body += buf; + remaining -= received; + } + + // Parse JSON + cJSON* root = cJSON_Parse(body.c_str()); + if (!root) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON"); + return ESP_FAIL; + } + + RoutePair route; + cJSON* item; + + item = cJSON_GetObjectItem(root, "line_code"); + if (item && cJSON_IsString(item)) route.line_code = item->valuestring; + + item = cJSON_GetObjectItem(root, "line_name"); + if (item && cJSON_IsString(item)) route.line_name = item->valuestring; + + item = cJSON_GetObjectItem(root, "line_color"); + if (item && cJSON_IsString(item)) route.line_color = item->valuestring; + + item = cJSON_GetObjectItem(root, "station_code"); + if (item && cJSON_IsString(item)) route.station_code = item->valuestring; + + item = cJSON_GetObjectItem(root, "station_name"); + if (item && cJSON_IsString(item)) route.station_name = item->valuestring; + + item = cJSON_GetObjectItem(root, "dest_code"); + if (item && cJSON_IsString(item)) route.dest_code = item->valuestring; + + item = cJSON_GetObjectItem(root, "dest_name"); + if (item && cJSON_IsString(item)) route.dest_name = item->valuestring; + + cJSON_Delete(root); + + if (route.line_code.empty() || route.station_code.empty() || route.dest_code.empty()) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing required fields"); + return ESP_FAIL; + } + + // Add route + handler->setting_handler_->add_route(route); + handler->setting_handler_->save_settings(); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"success\":true}", 16); + + return ESP_OK; +} + +esp_err_t WebHandler::remove_route_handler_(httpd_req_t* req) { + WebHandler* handler = static_cast(req->user_ctx); + + // Read request body + char buf[128]; + int received = 0; + int remaining = req->content_len; + + std::string body; + while (remaining > 0) { + received = httpd_req_recv(req, buf, std::min(remaining, (int)sizeof(buf) - 1)); + if (received <= 0) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Failed to read request"); + return ESP_FAIL; + } + buf[received] = '\0'; + body += buf; + remaining -= received; + } + + // Parse JSON + cJSON* root = cJSON_Parse(body.c_str()); + if (!root) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON"); + return ESP_FAIL; + } + + cJSON* index_item = cJSON_GetObjectItem(root, "index"); + if (!index_item || !cJSON_IsNumber(index_item)) { + cJSON_Delete(root); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing index"); + return ESP_FAIL; + } + + int index = index_item->valueint; + cJSON_Delete(root); + + handler->setting_handler_->remove_route(index); + handler->setting_handler_->save_settings(); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"success\":true}", 16); + + return ESP_OK; +} + +esp_err_t WebHandler::save_settings_handler_(httpd_req_t* req) { + WebHandler* handler = static_cast(req->user_ctx); + + // Read request body + char buf[256]; + int received = 0; + int remaining = req->content_len; + + std::string body; + while (remaining > 0) { + received = httpd_req_recv(req, buf, std::min(remaining, (int)sizeof(buf) - 1)); + if (received <= 0) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Failed to read request"); + return ESP_FAIL; + } + buf[received] = '\0'; + body += buf; + remaining -= received; + } + + // Parse JSON + cJSON* root = cJSON_Parse(body.c_str()); + if (!root) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON"); + return ESP_FAIL; + } + + cJSON* interval_item = cJSON_GetObjectItem(root, "polling_interval"); + if (interval_item && cJSON_IsNumber(interval_item)) { + uint32_t interval = interval_item->valueint; + handler->setting_handler_->set_polling_interval(interval); + } + + cJSON_Delete(root); + + handler->setting_handler_->save_settings(); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"success\":true}", 16); + + return ESP_OK; +} + +} // namespace travel diff --git a/main/ui/apps/travel/web/web_handlers.h b/main/ui/apps/travel/web/web_handlers.h new file mode 100644 index 0000000..eac6176 --- /dev/null +++ b/main/ui/apps/travel/web/web_handlers.h @@ -0,0 +1,58 @@ +#pragma once + +#include "esp_http_server.h" +#include +#include +#include "network/web_server_handler.h" +#include "ui/apps/travel/settings/settings_handler.h" +#include "external/mtr/mtr.h" +#include "network/network.h" + +namespace travel { + +/** + * @brief HTTP request handlers for Travel app settings web interface + * + * These handlers serve the web configuration page for MTR routes. + */ +class WebHandler { +public: + WebHandler( + SettingHandler* setting_handler, + NetworkHandler* network_handler + ); + ~WebHandler(); + + esp_err_t start_web_server(); + esp_err_t stop_web_server(); + + std::string get_url() const; + std::string get_device_ip() const; + uint16_t get_port() const; + + bool is_running() const { + return web_server_ && web_server_->is_running(); + } + +private: + std::string generate_auth_key_(); + esp_err_t register_web_endpoints_(); + + // HTTP handlers + static esp_err_t settings_page_handler_(httpd_req_t* req); + static esp_err_t get_lines_handler_(httpd_req_t* req); + static esp_err_t get_routes_handler_(httpd_req_t* req); + static esp_err_t add_route_handler_(httpd_req_t* req); + static esp_err_t remove_route_handler_(httpd_req_t* req); + static esp_err_t save_settings_handler_(httpd_req_t* req); + + std::unique_ptr web_server_; + SettingHandler* setting_handler_ = nullptr; + NetworkHandler* network_handler_ = nullptr; + std::unique_ptr mtr_handler_; + + std::string auth_key_; + static constexpr uint16_t WEB_SERVER_PORT = 8081; +}; + +} // namespace travel