6 Commits

Author SHA1 Message Date
e1f232eace Refactor WebSocket connection handlers: streamline logs and switch to warnings for closure events
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-10-17 09:58:28 +09:00
274b70dbbe Update header layout and footer version info placement
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-10-02 17:27:58 +09:00
3e7d3cabfe Remove debug logs from WebSocket ping/pong handlers
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-09-26 12:49:01 +09:00
40fd0a667a Add user settings feature to update username and password
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-09-26 12:47:03 +09:00
89e17efcfc Add websocket ping for detect connection lost for client
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-09-26 12:25:41 +09:00
fb2ab88bc9 Add NVS key for username and password
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-09-26 12:25:04 +09:00
10 changed files with 287 additions and 34 deletions

View File

@@ -42,6 +42,8 @@ enum nconfig_type
VIN_CURRENT_LIMIT, ///< The maximum current limit for the VIN.
MAIN_CURRENT_LIMIT, ///< The maximum current limit for the MAIN out.
USB_CURRENT_LIMIT, ///< The maximum current limit for the USB out.
PAGE_USERNAME, ///< Webpage username
PAGE_PASSWORD, ///< Webpage password
NCONFIG_TYPE_MAX, ///< Sentinel for the maximum number of configuration types.
};

View File

@@ -28,6 +28,8 @@ const static char* keys[NCONFIG_TYPE_MAX] = {
[VIN_CURRENT_LIMIT] = "vin_climit",
[MAIN_CURRENT_LIMIT] = "main_climit",
[USB_CURRENT_LIMIT] = "usb_climit",
[PAGE_USERNAME] = "username",
[PAGE_PASSWORD] = "password",
};
struct default_value
@@ -50,6 +52,8 @@ struct default_value const default_values[] = {
{VIN_CURRENT_LIMIT, "4.0"},
{MAIN_CURRENT_LIMIT, "3.0"},
{USB_CURRENT_LIMIT, "3.0"},
{PAGE_USERNAME, "admin"},
{PAGE_PASSWORD, "password"},
};
esp_err_t init_nconfig()

View File

@@ -177,6 +177,8 @@ static esp_err_t setting_post_handler(httpd_req_t* req)
cJSON* vin_climit_item = cJSON_GetObjectItem(root, "vin_current_limit");
cJSON* main_climit_item = cJSON_GetObjectItem(root, "main_current_limit");
cJSON* usb_climit_item = cJSON_GetObjectItem(root, "usb_current_limit");
cJSON* new_username_item = cJSON_GetObjectItem(root, "new_username");
cJSON* new_password_item = cJSON_GetObjectItem(root, "new_password");
if (mode_item && cJSON_IsString(mode_item))
{
@@ -322,6 +324,17 @@ static esp_err_t setting_post_handler(httpd_req_t* req)
}
httpd_resp_sendstr(req, "{\"status\":\"current_limit_updated\"}");
}
else if (new_username_item && cJSON_IsString(new_username_item) && new_password_item &&
cJSON_IsString(new_password_item))
{
const char* new_username = new_username_item->valuestring;
const char* new_password = new_password_item->valuestring;
nconfig_write(PAGE_USERNAME, new_username);
nconfig_write(PAGE_PASSWORD, new_password);
ESP_LOGI(TAG, "Username and password updated successfully.");
httpd_resp_sendstr(req, "{\"status\":\"user_credentials_updated\"}");
}
else
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid payload");

View File

@@ -80,12 +80,58 @@ static esp_err_t login_handler(httpd_req_t* req)
return ESP_FAIL;
}
const char* username = username_json->valuestring;
const char* password = password_json->valuestring;
const char* received_username = username_json->valuestring;
const char* received_password = password_json->valuestring;
// TODO: Implement actual credential validation
// For now, a simple hardcoded check
if (strcmp(username, "admin") == 0 && strcmp(password, "password") == 0)
// Get stored username and password from nconfig
size_t stored_username_len = 0;
size_t stored_password_len = 0;
char* stored_username = NULL;
char* stored_password = NULL;
bool credentials_match = false;
if (nconfig_get_str_len(PAGE_USERNAME, &stored_username_len) == ESP_OK && stored_username_len > 1)
{
stored_username = (char*)malloc(stored_username_len);
if (stored_username)
{
if (nconfig_read(PAGE_USERNAME, stored_username, stored_username_len) != ESP_OK)
{
ESP_LOGE(TAG, "Failed to read stored username from nconfig");
free(stored_username);
stored_username = NULL;
}
}
}
if (nconfig_get_str_len(PAGE_PASSWORD, &stored_password_len) == ESP_OK && stored_password_len > 1)
{
stored_password = (char*)malloc(stored_password_len);
if (stored_password)
{
if (nconfig_read(PAGE_PASSWORD, stored_password, stored_password_len) != ESP_OK)
{
ESP_LOGE(TAG, "Failed to read stored password from nconfig");
free(stored_password);
stored_password = NULL;
}
}
}
if (stored_username && stored_password)
{
if (strcmp(received_username, stored_username) == 0 && strcmp(received_password, stored_password) == 0)
{
credentials_match = true;
}
}
if (stored_username)
free(stored_username);
if (stored_password)
free(stored_password);
if (credentials_match)
{
char* token = auth_generate_token();
if (token)

View File

@@ -13,6 +13,7 @@
#include "pb.h"
#include "pb_encode.h"
#include "status.pb.h"
#include "string.h" // Added for strlen and strncmp
#include "webserver.h"
#define UART_NUM UART_NUM_1
@@ -264,7 +265,6 @@ static esp_err_t ws_handler(httpd_req_t* req)
httpd_ws_frame_t ws_pkt = {0};
uint8_t buf[BUF_SIZE];
ws_pkt.payload = buf;
ws_pkt.type = HTTPD_WS_TYPE_BINARY;
esp_err_t ret = httpd_ws_recv_frame(req, &ws_pkt, BUF_SIZE);
if (ret != ESP_OK)
@@ -273,7 +273,33 @@ static esp_err_t ws_handler(httpd_req_t* req)
return ret;
}
uart_write_bytes(UART_NUM, (const char*)ws_pkt.payload, ws_pkt.len);
if (ws_pkt.type == HTTPD_WS_TYPE_TEXT && ws_pkt.len == strlen("ping") &&
strncmp((const char*)ws_pkt.payload, "ping", ws_pkt.len) == 0)
{
ESP_LOGD(TAG, "Received application-level ping from client, sending pong.");
httpd_ws_frame_t pong_pkt = {
.payload = (uint8_t*)"pong", .len = strlen("pong"), .type = HTTPD_WS_TYPE_TEXT, .final = true};
return httpd_ws_send_frame(req, &pong_pkt);
}
else if (ws_pkt.type == HTTPD_WS_TYPE_CLOSE)
{
ESP_LOGI(TAG, "Client sent close frame, closing connection.");
return ESP_OK;
}
else if (ws_pkt.type == HTTPD_WS_TYPE_PING)
{
ESP_LOGD(TAG, "Received WebSocket PING control frame (handled by httpd).");
return ESP_OK;
}
else if (ws_pkt.type == HTTPD_WS_TYPE_PONG)
{
ESP_LOGD(TAG, "Received WebSocket PONG control frame.");
return ESP_OK;
}
else
{
uart_write_bytes(UART_NUM, (const char*)ws_pkt.payload, ws_pkt.len);
}
return ESP_OK;
}
@@ -327,4 +353,4 @@ void push_data_to_ws(const uint8_t* data, size_t len)
}
}
esp_err_t change_baud_rate(int baud_rate) { return uart_set_baudrate(UART_NUM, baud_rate); }
esp_err_t change_baud_rate(int baud_rate) { return uart_set_baudrate(UART_NUM, baud_rate); }

View File

@@ -53,7 +53,10 @@
<span id="power-display" class="text-primary">--.-- W</span>
</div>
</div>
<h1 class="text-primary text-center order-md-2 mx-auto">ODROID Power Mate</h1>
<div class="text-center order-md-2 mx-auto">
<h1 class="text-primary mb-0">ODROID Power Mate</h1>
<small class="text-muted" id="version-info"></small>
</div>
<div class="d-flex align-items-center justify-content-end order-md-3 header-controls" style="flex: 1;">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="theme-toggle">
@@ -159,12 +162,9 @@
</div>
</main>
<footer class="bg-body-tertiary text-center p-3 position-relative">
<footer class="bg-body-tertiary text-center p-3">
<a href="https://www.hardkernel.com/" target="_blank" class="link-secondary text-decoration-none">Hardkernel</a> |
<a href="https://wiki.odroid.com/start" target="_blank" class="link-secondary text-decoration-none">Wiki</a>
<div class="position-absolute end-0 top-50 translate-middle-y pe-3">
<small class="text-muted" id="version-info"></small>
</div>
</footer>
<!-- Settings Modal -->
@@ -197,6 +197,11 @@
id="current-limit-settings-tab" role="tab" type="button">Current Limit
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-target="#user-settings-pane" data-bs-toggle="tab"
id="user-settings-tab" role="tab" type="button">User Settings
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="device-settings-tab" data-bs-toggle="tab"
data-bs-target="#device-settings-pane" type="button" role="tab">Device
@@ -331,6 +336,28 @@
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
<div class="tab-pane fade" id="user-settings-pane" role="tabpanel">
<form id="user-settings-form">
<div class="mb-3">
<label class="form-label" for="new-username">New Username</label>
<input class="form-control" id="new-username" required type="text">
</div>
<div class="mb-3">
<label class="form-label" for="new-password">New Password</label>
<input class="form-control" id="new-password" required type="password">
</div>
<div class="mb-3">
<label class="form-label" for="confirm-password">Confirm New Password</label>
<input class="form-control" id="confirm-password" required type="password">
</div>
<div class="d-flex justify-content-end pt-3 border-top mt-3">
<button class="btn btn-primary me-2" id="user-settings-apply-button" type="submit">
Apply
</button>
<button class="btn btn-secondary" data-bs-dismiss="modal" type="button">Close</button>
</div>
</form>
</div>
<div class="tab-pane fade" id="device-settings-pane" role="tabpanel">
<div class="mb-3">
<label for="baud-rate-select" class="form-label">UART Baud Rate</label>

View File

@@ -171,3 +171,22 @@ export async function fetchVersion() {
});
return await handleResponse(response).then(res => res.json());
}
/**
* Updates the user's username and password on the server.
* @param {string} newUsername The new username.
* @param {string} newPassword The new password.
* @returns {Promise<Object>} A promise that resolves to the server's JSON response.
* @throws {Error} Throws an error if the update fails.
*/
export async function updateUserSettings(newUsername, newPassword) {
const response = await fetch('/api/setting', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders(),
},
body: JSON.stringify({new_username: newUsername, new_password: newPassword}),
});
return await handleResponse(response).then(res => res.json());
}

View File

@@ -7,10 +7,10 @@
import * as dom from './dom.js';
import * as api from './api.js';
import {getAuthHeaders, handleResponse} from './api.js'; // Import auth functions
import * as ui from './ui.js';
import {clearTerminal, downloadTerminalOutput, fitTerminal} from './terminal.js';
import {debounce, isMobile} from './utils.js';
import {getAuthHeaders, handleResponse} from './api.js'; // Import auth functions
// A flag to track if charts have been initialized
let chartsInitialized = false;
@@ -60,7 +60,6 @@ export function setupEventListeners() {
console.log("Event listeners already attached. Skipping.");
return;
}
console.log("Attaching event listeners...");
// --- Terminal Controls ---
dom.clearButton.addEventListener('click', clearTerminal);
@@ -189,5 +188,4 @@ export function setupEventListeners() {
window.addEventListener('resize', debounce(ui.handleResize, 150));
listenersAttached = true;
console.log("Event listeners attached successfully.");
}

View File

@@ -44,21 +44,23 @@ const themeIconLogin = document.getElementById('theme-icon-login');
const themeToggleMain = document.getElementById('theme-toggle');
const themeIconMain = document.getElementById('theme-icon');
// User Settings DOM Elements
const userSettingsForm = document.getElementById('user-settings-form');
const newUsernameInput = document.getElementById('new-username');
const newPasswordInput = document.getElementById('new-password');
const confirmPasswordInput = document.getElementById('confirm-password');
// --- WebSocket Event Handlers ---
function onWsOpen() {
updateWebsocketStatus(true);
if (term) {
term.write('\x1b[32mConnected to WebSocket Server\x1b[0m\r\n');
}
console.log('Connected to WebSocket Server');
}
function onWsClose() {
updateWebsocketStatus(false);
if (term) {
term.write('\r\n\x1b[31mConnection closed. Reconnecting...\x1b[0m\r\n');
}
console.warn('Connection closed. Reconnecting...');
setTimeout(connect, 2000);
}
@@ -168,6 +170,38 @@ function handleLogout() {
// For now, just hide the main content.
}
// --- User Settings Functions ---
async function handleUserSettingsSubmit(event) {
event.preventDefault();
const newUsername = newUsernameInput.value;
const newPassword = newPasswordInput.value;
const confirmPassword = confirmPasswordInput.value;
if (!newUsername || !newPassword || !confirmPassword) {
alert('Please fill in all fields for username and password.');
return;
}
if (newPassword !== confirmPassword) {
alert('New password and confirm password do not match.');
return;
}
try {
const response = await api.updateUserSettings(newUsername, newPassword);
if (response && response.status === 'user_credentials_updated') {
alert('Username and password updated successfully. Please log in again with new credentials.');
handleLogout(); // Force logout to re-authenticate with new credentials
} else {
alert(`Failed to update credentials: ${response.message || 'Unknown error'}`);
}
} catch (error) {
console.error('Error updating user settings:', error);
alert(`Error updating user settings: ${error.message}`);
}
}
// --- Theme Toggle Functions ---
function setupThemeToggles() {
// Initialize theme for login page
@@ -230,6 +264,11 @@ function initializeMainAppContent() {
setupEventListeners(); // Attach main app event listeners
logoutButton.addEventListener('click', handleLogout); // Attach logout listener
connect();
// Attach user settings form listener
if (userSettingsForm) {
userSettingsForm.addEventListener('submit', handleUserSettingsSubmit);
}
}
function initialize() {

View File

@@ -2,7 +2,7 @@
* @file websocket.js
* @description This module handles the WebSocket connection for real-time, two-way
* communication with the server. It provides functions to initialize the connection
* and send messages.
* and send messages, including a heartbeat mechanism to detect disconnections.
*/
// The WebSocket instance, exported for potential direct access if needed.
@@ -11,14 +11,57 @@ export let websocket;
// The WebSocket server address, derived from the current page's host (hostname + port).
const baseGateway = `ws://${window.location.host}/ws`;
// Heartbeat related variables
let pingIntervalId = null;
let pongTimeoutId = null;
const HEARTBEAT_INTERVAL = 10000; // 10 seconds: How often to send a 'ping'
const HEARTBEAT_TIMEOUT = 5000; // 5 seconds: How long to wait for a 'pong' after sending a 'ping'
/**
* Initializes the WebSocket connection and sets up event handlers.
* @param {Object} callbacks - An object containing callback functions for WebSocket events.
* @param {function} callbacks.onOpen - Called when the connection is successfully opened.
* @param {function} callbacks.onClose - Called when the connection is closed.
* @param {function} callbacks.onMessage - Called when a message is received from the server.
* Starts the heartbeat mechanism.
* Sends a 'ping' message to the server at regular intervals and sets a timeout
* to detect if a 'pong' response is not received.
*/
export function initWebSocket({onOpen, onClose, onMessage}) {
function startHeartbeat() {
stopHeartbeat(); // Ensure any previous heartbeat is stopped before starting a new one
pingIntervalId = setInterval(() => {
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send('ping');
// Set a timeout to check if a pong is received within HEARTBEAT_TIMEOUT
pongTimeoutId = setTimeout(() => {
console.warn('WebSocket: No pong received within timeout, closing connection.');
// If no pong is received, close the connection. This will trigger the onClose handler.
websocket.close();
}, HEARTBEAT_TIMEOUT);
}
}, HEARTBEAT_INTERVAL);
}
/**
* Stops the heartbeat mechanism by clearing the ping interval and pong timeout.
*/
function stopHeartbeat() {
if (pingIntervalId) {
clearInterval(pingIntervalId);
pingIntervalId = null;
}
if (pongTimeoutId) {
clearTimeout(pongTimeoutId);
pongTimeoutId = null;
}
}
/**
* Initializes the WebSocket connection and sets up event handlers, including a heartbeat mechanism.
* @param {Object} callbacks - An object containing callback functions for WebSocket events.
* @param {function} [callbacks.onOpen] - Called when the connection is successfully opened.
* @param {function} [callbacks.onClose] - Called when the connection is closed.
* @param {function} [callbacks.onMessage] - Called when a message is received from the server (excluding 'pong' messages).
* @param {function} [callbacks.onError] - Called when an error occurs with the WebSocket connection.
*/
export function initWebSocket({onOpen, onClose, onMessage, onError}) {
const token = localStorage.getItem('authToken');
let gateway = baseGateway;
@@ -31,10 +74,44 @@ export function initWebSocket({onOpen, onClose, onMessage}) {
// Set binary type to arraybuffer to handle raw binary data from the UART.
websocket.binaryType = "arraybuffer";
// Assign event handlers from the provided callbacks
if (onOpen) websocket.onopen = onOpen;
if (onClose) websocket.onclose = onClose;
if (onMessage) websocket.onmessage = onMessage;
// Assign event handlers, wrapping user-provided callbacks to include heartbeat logic
websocket.onopen = (event) => {
console.log('WebSocket connection opened.');
startHeartbeat(); // Start heartbeat on successful connection
if (onOpen) {
onOpen(event);
}
};
websocket.onclose = (event) => {
console.log('WebSocket connection closed:', event);
stopHeartbeat(); // Stop heartbeat when connection closes
if (onClose) {
onClose(event);
}
};
websocket.onmessage = (event) => {
if (event.data === 'pong') {
// Clear the timeout as pong was received, resetting for the next ping
clearTimeout(pongTimeoutId);
pongTimeoutId = null;
} else {
// If it's not a pong message, pass it to the user's onMessage callback
if (onMessage) {
onMessage(event);
} else {
console.log('WebSocket message received:', event.data);
}
}
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
if (onError) {
onError(error);
}
};
}
/**
@@ -44,5 +121,7 @@ export function initWebSocket({onOpen, onClose, onMessage}) {
export function sendWebsocketMessage(data) {
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(data);
} else {
console.warn('WebSocket is not open. Message not sent:', data);
}
}
}