feat(travel): Implement settings UI and web server for MTR route configuration
- Added MainUIHandler class to manage the main UI and polling for arrival data. - Introduced SettingsUI class for displaying QR code and configuration options. - Created SettingsUIHandler to manage settings UI lifecycle and web server interactions. - Developed WebHandler to handle HTTP requests for MTR route settings, including adding and removing routes. - Implemented web endpoints for fetching MTR lines, routes, and saving settings. - Enhanced UI with responsive design for e-ink displays and added error handling for web interactions.
This commit is contained in:
4
main/external/mtr/arrival.cpp
vendored
4
main/external/mtr/arrival.cpp
vendored
@@ -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");
|
||||
|
||||
8
main/external/mtr/line_info.cpp
vendored
8
main/external/mtr/line_info.cpp
vendored
@@ -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)) {
|
||||
|
||||
5
main/external/mtr/line_info.h
vendored
5
main/external/mtr/line_info.h
vendored
@@ -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<StationInfo> _stations;
|
||||
};
|
||||
|
||||
65
main/external/mtr/mtr.cpp
vendored
65
main/external/mtr/mtr.cpp
vendored
@@ -8,12 +8,13 @@
|
||||
#include "cJSON.h"
|
||||
#include "esp_log.h"
|
||||
#include <string>
|
||||
#include <sstream>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#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;
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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<IotDisDescriptor>());
|
||||
register_app(std::make_unique<TravelDescriptor>());
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
11
main/ui/apps/travel/CMakeLists.txt
Normal file
11
main/ui/apps/travel/CMakeLists.txt
Normal file
@@ -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"
|
||||
)
|
||||
131
main/ui/apps/travel/app.cpp
Normal file
131
main/ui/apps/travel/app.cpp
Normal file
@@ -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<travel::SettingHandler>(
|
||||
std::make_unique<NVSStorageHandler>(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<travel::MainUIHandler>();
|
||||
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<travel::MainUIHandler>();
|
||||
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<travel::SettingsUIHandler>();
|
||||
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<TravelApp*>(user_data);
|
||||
if (app) {
|
||||
app->show_settings_page();
|
||||
}
|
||||
}
|
||||
72
main/ui/apps/travel/app.h
Normal file
72
main/ui/apps/travel/app.h
Normal file
@@ -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 <string>
|
||||
#include <memory>
|
||||
|
||||
// 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<travel::MainUIHandler> main_ui_handler_;
|
||||
std::unique_ptr<travel::SettingsUIHandler> settings_ui_handler_;
|
||||
|
||||
// Current page tracking
|
||||
enum class Page {
|
||||
MAIN,
|
||||
SETTINGS
|
||||
};
|
||||
Page current_page_;
|
||||
|
||||
// Settings handler (shared across handlers)
|
||||
std::unique_ptr<travel::SettingHandler> 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);
|
||||
};
|
||||
12
main/ui/apps/travel/descriptor.cpp
Normal file
12
main/ui/apps/travel/descriptor.cpp
Normal file
@@ -0,0 +1,12 @@
|
||||
#include "ui/apps/travel/descriptor.h"
|
||||
#include "ui/apps/travel/app.h"
|
||||
|
||||
TravelDescriptor::TravelDescriptor()
|
||||
: AppDescriptor("Travel", std::make_unique<TravelApp>()) { }
|
||||
|
||||
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);
|
||||
}
|
||||
14
main/ui/apps/travel/descriptor.h
Normal file
14
main/ui/apps/travel/descriptor.h
Normal file
@@ -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;
|
||||
};
|
||||
170
main/ui/apps/travel/settings/settings_handler.cpp
Normal file
170
main/ui/apps/travel/settings/settings_handler.cpp
Normal file
@@ -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<NVSStorageHandler> 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<int>(routes_.size()), static_cast<int>(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<int>(routes_.size()), static_cast<int>(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<int>(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<int>(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<int>(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<int>(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<int>(routes_.size()));
|
||||
}
|
||||
|
||||
} // namespace travel
|
||||
58
main/ui/apps/travel/settings/settings_handler.h
Normal file
58
main/ui/apps/travel/settings/settings_handler.h
Normal file
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#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<NVSStorageHandler> 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<RoutePair>& 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<RoutePair> routes_;
|
||||
uint32_t polling_interval_sec_ = DEFAULT_POLLING_INTERVAL;
|
||||
std::unique_ptr<NVSStorageHandler> storage_;
|
||||
|
||||
// JSON serialization helpers
|
||||
std::string routes_to_json() const;
|
||||
void routes_from_json(const std::string& json);
|
||||
};
|
||||
|
||||
} // namespace travel
|
||||
46
main/ui/apps/travel/types.h
Normal file
46
main/ui/apps/travel/types.h
Normal file
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<ArrivalDisplay> arrivals; // List of upcoming trains to destination
|
||||
bool is_valid = false;
|
||||
std::string error_message;
|
||||
};
|
||||
|
||||
} // namespace travel
|
||||
276
main/ui/apps/travel/ui/main.cpp
Normal file
276
main/ui/apps/travel/ui/main.cpp
Normal file
@@ -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<RouteArrivalData>& 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<int>(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<int>(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
|
||||
59
main/ui/apps/travel/ui/main.h
Normal file
59
main/ui/apps/travel/ui/main.h
Normal file
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
#include "lvgl.h"
|
||||
#include "esp_err.h"
|
||||
#include "ui/apps/travel/types.h"
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
||||
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<RouteArrivalData>& 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
|
||||
249
main/ui/apps/travel/ui/main_handler.cpp
Normal file
249
main/ui/apps/travel/ui/main_handler.cpp
Normal file
@@ -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 <ctime>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
|
||||
static const char* TAG = "TravelMainHandler";
|
||||
|
||||
namespace travel {
|
||||
|
||||
MainUIHandler::MainUIHandler()
|
||||
: main_ui_(std::make_unique<MainUI>())
|
||||
, mtr_handler_(std::make_unique<MTRNextTrainHandler>()) {
|
||||
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<MainUIHandler*>(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<RouteArrivalData> 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<ArrivalInfo>* 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<MainUIHandler*>(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
|
||||
69
main/ui/apps/travel/ui/main_handler.h
Normal file
69
main/ui/apps/travel/ui/main_handler.h
Normal file
@@ -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 <memory>
|
||||
#include <vector>
|
||||
#include <atomic>
|
||||
|
||||
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<MainUI> main_ui_;
|
||||
SettingHandler* setting_handler_ = nullptr;
|
||||
NetworkHandler* network_handler_ = nullptr;
|
||||
std::unique_ptr<MTRNextTrainHandler> mtr_handler_;
|
||||
|
||||
// Polling task
|
||||
TaskHandle_t polling_task_handle_ = nullptr;
|
||||
std::atomic<bool> 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
|
||||
150
main/ui/apps/travel/ui/settings.cpp
Normal file
150
main/ui/apps/travel/ui/settings.cpp
Normal file
@@ -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
|
||||
38
main/ui/apps/travel/ui/settings.h
Normal file
38
main/ui/apps/travel/ui/settings.h
Normal file
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include "lvgl.h"
|
||||
#include "esp_err.h"
|
||||
#include <string>
|
||||
|
||||
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
|
||||
85
main/ui/apps/travel/ui/settings_handler.cpp
Normal file
85
main/ui/apps/travel/ui/settings_handler.cpp
Normal file
@@ -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<SettingsUI>())
|
||||
, 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<WebHandler>(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
|
||||
40
main/ui/apps/travel/ui/settings_handler.h
Normal file
40
main/ui/apps/travel/ui/settings_handler.h
Normal file
@@ -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 <memory>
|
||||
|
||||
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<SettingsUI> settings_ui_;
|
||||
std::unique_ptr<WebHandler> web_handler_;
|
||||
SettingHandler* setting_handler_ = nullptr;
|
||||
NetworkHandler* network_handler_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace travel
|
||||
718
main/ui/apps/travel/web/web_handlers.cpp
Normal file
718
main/ui/apps/travel/web/web_handlers.cpp
Normal file
@@ -0,0 +1,718 @@
|
||||
#include "ui/apps/travel/web/web_handlers.h"
|
||||
#include "esp_log.h"
|
||||
#include "cJSON.h"
|
||||
#include <cstring>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
|
||||
static const char* TAG = "TravelWebHandler";
|
||||
|
||||
namespace travel {
|
||||
|
||||
WebHandler::WebHandler(
|
||||
SettingHandler* setting_handler,
|
||||
NetworkHandler* network_handler
|
||||
)
|
||||
: web_server_(std::make_unique<WebServerHandler>())
|
||||
, setting_handler_(setting_handler)
|
||||
, network_handler_(network_handler)
|
||||
, mtr_handler_(std::make_unique<MTRNextTrainHandler>())
|
||||
, 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<WebHandler*>(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(
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MTR Travel Settings</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 { font-size: 24px; margin-bottom: 20px; color: #333; }
|
||||
h2 { font-size: 18px; margin: 20px 0 10px; color: #555; }
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.form-group { margin-bottom: 15px; }
|
||||
label { display: block; margin-bottom: 5px; font-weight: 500; color: #555; }
|
||||
select, input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
button {
|
||||
background: #007DC5;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
button:hover { background: #005a8c; }
|
||||
button.secondary {
|
||||
background: #6c757d;
|
||||
}
|
||||
button.danger {
|
||||
background: #dc3545;
|
||||
}
|
||||
.route-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.route-info { flex: 1; }
|
||||
.route-line {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.status { margin-top: 10px; padding: 10px; border-radius: 4px; }
|
||||
.status.success { background: #d4edda; color: #155724; }
|
||||
.status.error { background: #f8d7da; color: #721c24; }
|
||||
.slider-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
input[type="range"] { flex: 1; }
|
||||
.value-display { min-width: 60px; text-align: right; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>MTR Travel 設定</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>新增路線</h2>
|
||||
<div class="form-group">
|
||||
<label>路線</label>
|
||||
<select id="line_select" onchange="updateStations()">
|
||||
<option value="">請選擇路線</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>出發站</label>
|
||||
<select id="station_select">
|
||||
<option value="">請先選擇路線</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>目的地</label>
|
||||
<select id="dest_select">
|
||||
<option value="">請先選擇路線</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="addRoute()">新增路線</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>已儲存路線</h2>
|
||||
<div id="routes_list"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>更新頻率</h2>
|
||||
<div class="form-group">
|
||||
<label>資料更新間隔</label>
|
||||
<div class="slider-container">
|
||||
<input type="range" id="interval_slider" min="10" max="120" value="30" step="5"
|
||||
oninput="updateIntervalDisplay()">
|
||||
<span class="value-display"><span id="interval_value">30</span> 秒</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="saveSettings()">儲存設定</button>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
|
||||
<script>
|
||||
let linesData = [];
|
||||
let routesData = [];
|
||||
|
||||
// Load initial data
|
||||
async function init() {
|
||||
await loadLines();
|
||||
await loadRoutes();
|
||||
updateIntervalDisplay();
|
||||
}
|
||||
|
||||
async function loadLines() {
|
||||
try {
|
||||
const response = await fetch('/api/lines');
|
||||
linesData = await response.json();
|
||||
const select = document.getElementById('line_select');
|
||||
select.innerHTML = '<option value="">請選擇路線</option>';
|
||||
linesData.forEach(line => {
|
||||
const option = document.createElement('option');
|
||||
option.value = line.code;
|
||||
option.textContent = line.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
} catch (err) {
|
||||
showStatus('無法載入路線資料', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRoutes() {
|
||||
try {
|
||||
const response = await fetch('/api/routes');
|
||||
const data = await response.json();
|
||||
routesData = data.routes || [];
|
||||
document.getElementById('interval_slider').value = data.polling_interval || 30;
|
||||
renderRoutes();
|
||||
} catch (err) {
|
||||
showStatus('無法載入路線', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateStations() {
|
||||
const lineCode = document.getElementById('line_select').value;
|
||||
const stationSelect = document.getElementById('station_select');
|
||||
const destSelect = document.getElementById('dest_select');
|
||||
|
||||
if (!lineCode) {
|
||||
stationSelect.innerHTML = '<option value="">請先選擇路線</option>';
|
||||
destSelect.innerHTML = '<option value="">請先選擇路線</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
const line = linesData.find(l => l.code === lineCode);
|
||||
if (!line) return;
|
||||
|
||||
const stationsHtml = line.stations.map(s =>
|
||||
`<option value="${s.code}">${s.name}</option>`
|
||||
).join('');
|
||||
|
||||
stationSelect.innerHTML = '<option value="">請選擇車站</option>' + stationsHtml;
|
||||
destSelect.innerHTML = '<option value="">請選擇目的地</option>' + stationsHtml;
|
||||
}
|
||||
|
||||
function renderRoutes() {
|
||||
const container = document.getElementById('routes_list');
|
||||
if (routesData.length === 0) {
|
||||
container.innerHTML = '<p style="color: #666;">尚未設定路線</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = routesData.map((route, index) => `
|
||||
<div class="route-item">
|
||||
<div class="route-info">
|
||||
<span class="route-line" style="background: ${route.line_color}">${route.line_name}</span>
|
||||
${route.station_name} → ${route.dest_name}
|
||||
</div>
|
||||
<button class="danger" style="width: auto; padding: 5px 10px;"
|
||||
onclick="removeRoute(${index})">刪除</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function addRoute() {
|
||||
const lineCode = document.getElementById('line_select').value;
|
||||
const stationCode = document.getElementById('station_select').value;
|
||||
const destCode = document.getElementById('dest_select').value;
|
||||
|
||||
if (!lineCode || !stationCode || !destCode) {
|
||||
showStatus('請選擇路線、出發站和目的地', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (stationCode === destCode) {
|
||||
showStatus('出發站和目的地不能相同', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const line = linesData.find(l => l.code === lineCode);
|
||||
const station = line.stations.find(s => s.code === stationCode);
|
||||
const dest = line.stations.find(s => s.code === destCode);
|
||||
|
||||
const route = {
|
||||
line_code: lineCode,
|
||||
line_name: line.name,
|
||||
line_color: line.color,
|
||||
station_code: stationCode,
|
||||
station_name: station.name,
|
||||
dest_code: destCode,
|
||||
dest_name: dest.name
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/routes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(route)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('路線已新增', 'success');
|
||||
await loadRoutes();
|
||||
} else {
|
||||
const err = await response.text();
|
||||
showStatus('新增失敗: ' + err, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showStatus('新增失敗', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRoute(index) {
|
||||
try {
|
||||
const response = await fetch('/api/routes', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ index: index })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('路線已刪除', 'success');
|
||||
await loadRoutes();
|
||||
} else {
|
||||
showStatus('刪除失敗', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showStatus('刪除失敗', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateIntervalDisplay() {
|
||||
const value = document.getElementById('interval_slider').value;
|
||||
document.getElementById('interval_value').textContent = value;
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const interval = parseInt(document.getElementById('interval_slider').value);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ polling_interval: interval })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('設定已儲存', 'success');
|
||||
} else {
|
||||
showStatus('儲存失敗', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showStatus('儲存失敗', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showStatus(message, type) {
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.className = 'status ' + type;
|
||||
statusDiv.textContent = message;
|
||||
setTimeout(() => {
|
||||
statusDiv.className = '';
|
||||
statusDiv.textContent = '';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
)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<WebHandler*>(req->user_ctx);
|
||||
|
||||
// Get all lines from MTR handler
|
||||
std::vector<LineInfo> 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<WebHandler*>(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<WebHandler*>(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<WebHandler*>(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<WebHandler*>(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
|
||||
58
main/ui/apps/travel/web/web_handlers.h
Normal file
58
main/ui/apps/travel/web/web_handlers.h
Normal file
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_http_server.h"
|
||||
#include <string>
|
||||
#include <memory>
|
||||
#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<WebServerHandler> web_server_;
|
||||
SettingHandler* setting_handler_ = nullptr;
|
||||
NetworkHandler* network_handler_ = nullptr;
|
||||
std::unique_ptr<MTRNextTrainHandler> mtr_handler_;
|
||||
|
||||
std::string auth_key_;
|
||||
static constexpr uint16_t WEB_SERVER_PORT = 8081;
|
||||
};
|
||||
|
||||
} // namespace travel
|
||||
Reference in New Issue
Block a user