Files
ink-board/main/ui/apps/travel/web/web_handlers.cpp
GW_MC c4635948e4 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.
2026-02-03 19:26:53 +08:00

719 lines
20 KiB
C++

#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