init commit

Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
This commit is contained in:
2025-08-20 18:56:07 +09:00
commit 2383894664
46 changed files with 7834 additions and 0 deletions

24
page/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

319
page/index.html Normal file
View File

@@ -0,0 +1,319 @@
<!DOCTYPE HTML>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<title>ODROID Remote</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script type="module" src="/src/main.js"></script>
</head>
<body>
<main class="container">
<header class="d-flex justify-content-between align-items-center mb-3 main-header">
<div class="order-md-1" style="flex: 1;">
<div class="d-flex align-items-center">
<span id="wifi-status" class="d-flex align-items-center text-muted">
<i id="wifi-icon" class="bi bi-wifi-off me-2"></i>
<span id="wifi-ssid-status">Disconnected</span>
</span>
<span id="websocket-status" class="d-flex align-items-center text-danger ms-3">
<i id="websocket-icon" class="bi bi-x-circle-fill me-2"></i>
<span id="websocket-status-text">Offline</span>
</span>
</div>
<div class="font-monospace mt-1 d-none d-md-inline">
<span id="voltage-display" class="text-primary">--.-- V</span> |
<span id="current-display" class="text-primary">--.-- A</span> |
<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="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">
<label class="form-check-label" for="theme-toggle"><i id="theme-icon" class="bi bi-moon-stars-fill"></i></label>
</div>
<button class="btn btn-outline-secondary ms-3" id="settings-button" data-bs-toggle="modal"
data-bs-target="#settingsModal">
<i class="bi bi-gear"></i>
</button>
</div>
</header>
<ul class="nav nav-tabs" id="main-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="terminal-tab-btn" data-bs-toggle="tab"
data-bs-target="#terminal-tab-pane" type="button" role="tab">Terminal
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="graph-tab-btn" data-bs-toggle="tab" data-bs-target="#graph-tab-pane"
type="button" role="tab">Metrics
</button>
</li>
</ul>
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="terminal-tab-pane" role="tabpanel">
<div class="card border-top-0 rounded-0 rounded-bottom">
<div class="card-body">
<div id="terminal-wrapper">
<div id="terminal-container" class="border rounded">
<div id="terminal"></div>
</div>
</div>
<div class="d-flex justify-content-end mt-3">
<button id="download-button" class="btn btn-primary me-2"><i class="bi bi-download me-1"></i>Download Log</button>
<button id="clear-button" class="btn btn-secondary">Clear Terminal</button>
</div>
<!-- Moved from Control Tab -->
<div class="mt-4">
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center control-list-item">
Main Power (12V)
<div class="control-wrapper">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="main-power-toggle">
</div>
</div>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center control-list-item">
USB Power (5V)
<div class="control-wrapper">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="usb-power-toggle">
</div>
</div>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center control-list-item">
Power Actions
<div class="control-wrapper">
<button id="reset-button" class="btn btn-secondary">Reset</button>
<button id="power-action-button" class="btn btn-danger ms-2">Power</button>
</div>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
System Uptime
<div class="control-wrapper">
<span id="uptime-display" class="font-monospace text-success">--:--:--</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="graph-tab-pane" role="tabpanel">
<div class="card border-top-0 rounded-0 rounded-bottom">
<div class="card-body">
<div class="d-flex justify-content-end mb-3">
<a href="/datalog.csv" class="btn btn-primary" download="datalog.csv"><i class="bi bi-download me-1"></i> Download CSV</a>
</div>
<h5 class="card-title text-center mb-3">Power Input</h5>
<div class="row">
<div class="col-md-4 mb-3 mb-md-0">
<canvas id="powerChart" class="chart-canvas"></canvas>
</div>
<div class="col-md-4 mb-3 mb-md-0">
<canvas id="voltageChart" class="chart-canvas"></canvas>
</div>
<div class="col-md-4">
<canvas id="currentChart" class="chart-canvas"></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="bg-body-tertiary text-center p-3">
<a href="https://www.hardkernel.com/" target="_blank" class="link-secondary">Hardkernel</a> |
<a href="https://wiki.odroid.com/start" target="_blank" class="link-secondary">Wiki</a>
</footer>
<!-- Settings Modal -->
<div class="modal fade" id="settingsModal" tabindex="-1" aria-labelledby="settingsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="settingsModalLabel">Settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs" id="settingsTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="wifi-settings-tab" data-bs-toggle="tab"
data-bs-target="#wifi-settings-pane" type="button" role="tab">Wi-Fi
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="network-settings-tab" data-bs-toggle="tab"
data-bs-target="#network-settings-pane" type="button" role="tab">Network
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="ap-mode-settings-tab" data-bs-toggle="tab"
data-bs-target="#ap-mode-settings-pane" type="button" role="tab">AP Mode
</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
</button>
</li>
</ul>
<div class="tab-content pt-3" id="settingsTabContent">
<div class="tab-pane fade show active" id="wifi-settings-pane" role="tabpanel">
<div class="alert alert-danger mt-3" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>Warning:</strong> Disconnecting or connecting to a new network will restart the Wi-Fi service. You may need to reconnect to the device.
</div>
<div class="mb-3">
<h6>Current Connection</h6>
<div class="d-flex justify-content-between align-items-center p-2 rounded bg-body-secondary">
<div>
<strong>Connected to: </strong><span id="current-wifi-ssid">MyHome_WiFi</span><br>
<small class="text-muted" id="current-wifi-ip">IP Address: -</small>
</div>
<!-- <button class="btn btn-warning btn-sm" id="wifi-disconnect-button">Disconnect</button>-->
</div>
</div>
<hr>
<h6>Available Wi-Fi Networks</h6>
<div class="table-responsive" style="max-height: 200px;">
<table class="table table-hover table-sm">
<thead>
<tr>
<th scope="col">SSID</th>
<th scope="col">Signal</th>
<th scope="col">Security</th>
</tr>
</thead>
<tbody id="wifi-ap-list">
<tr><td colspan="3" class="text-center text-muted">Click 'Scan' to find networks.</td></tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-end pt-3 border-top mt-3">
<button type="button" class="btn btn-primary me-2" id="scan-wifi-button">Scan</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
<div class="tab-pane fade" id="network-settings-pane" role="tabpanel">
<div class="alert alert-danger mt-3" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>Warning:</strong> Applying these settings will restart the Wi-Fi service. You may need to reconnect to the device.
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" id="static-ip-toggle">
<label class="form-check-label" for="static-ip-toggle">Use Static IP</label>
</div>
<div id="static-ip-config" style="display: none;">
<div class="mb-3">
<label for="ip-address" class="form-label">IP Address</label>
<input type="text" class="form-control" id="ip-address" placeholder="192.168.0.100">
</div>
<div class="mb-3">
<label for="gateway" class="form-label">Gateway</label>
<input type="text" class="form-control" id="gateway" placeholder="192.168.0.1">
</div>
<div class="mb-3">
<label for="netmask" class="form-label">Netmask</label>
<input type="text" class="form-control" id="netmask" placeholder="255.255.255.0">
</div>
<div class="mb-3">
<label for="dns1" class="form-label">DNS Server</label>
<input type="text" class="form-control" id="dns1" placeholder="8.8.8.8">
</div>
<div class="mb-3">
<label for="dns2" class="form-label">Secondary DNS Server</label>
<input type="text" class="form-control" id="dns2" placeholder="8.8.4.4">
</div>
</div>
<div class="d-flex justify-content-end pt-3 border-top mt-3">
<button type="button" class="btn btn-primary me-2" id="network-apply-button">Apply</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
<div class="tab-pane fade" id="ap-mode-settings-pane" role="tabpanel">
<div class="alert alert-danger mt-3" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>Warning:</strong> Applying these settings will restart the Wi-Fi service. You may need to reconnect to the device.
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" id="ap-mode-toggle">
<label class="form-check-label" for="ap-mode-toggle">Enable AP+STA Mode</label>
</div>
<p class="text-muted small">Disable to use Station (STA) mode only. Enable to use Access Point + Station (APSTA) mode, allowing other devices to connect to this one.</p>
<div id="ap-mode-config" style="display: none;">
<div class="mb-3">
<label for="ap-ssid" class="form-label">AP SSID</label>
<input type="text" class="form-control" id="ap-ssid" placeholder="ODROID-Remote-AP">
</div>
<div class="mb-3">
<label for="ap-password" class="form-label">AP Password</label>
<input type="password" class="form-control" id="ap-password" placeholder="Leave blank for open network">
</div>
</div>
<div class="d-flex justify-content-end pt-3 border-top mt-3">
<button type="button" class="btn btn-primary me-2" id="ap-mode-apply-button">Apply</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</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>
<select class="form-select" id="baud-rate-select">
<option value="9600">9600</option>
<option value="19200">19200</option>
<option value="38400">38400</option>
<option value="57600">57600</option>
<option value="115200">115200</option>
<option value="230400">230400</option>
<option value="460800">460800</option>
<option value="921600">921600</option>
<option value="1500000" selected>1500000</option>
</select>
</div>
<div class="d-flex justify-content-end pt-3 border-top mt-3">
<button type="button" class="btn btn-primary me-2" id="baud-rate-apply-button">Apply</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Wi-Fi Connection Modal -->
<div class="modal fade" id="wifiModal" tabindex="-1" aria-labelledby="wifiModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="wifiModalLabel">Connect to Wi-Fi</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="wifi-ssid-connect" class="form-label">SSID</label>
<input type="text" class="form-control" id="wifi-ssid-connect" readonly>
</div>
<div class="mb-3">
<label for="wifi-password-connect" class="form-label">Password</label>
<input type="password" class="form-control" id="wifi-password-connect">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="wifi-connect-button">Connect</button>
</div>
</div>
</div>
</div>
</body>
</html>

1283
page/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
page/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "odroid-remote-page",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^7.0.4",
"vite-plugin-singlefile": "^2.0.1",
"vite-plugin-compression": "^0.5.1"
},
"dependencies": {
"@xterm/xterm": "^5.5.0",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"@xterm/addon-fit": "^0.9.0",
"chart.js": "^4.4.3"
}
}

112
page/src/api.js Normal file
View File

@@ -0,0 +1,112 @@
/**
* @file api.js
* @description This module centralizes all API calls to the server's RESTful endpoints.
* It abstracts the fetch logic, error handling, and JSON parsing for network and control operations.
*/
/**
* Fetches the list of available Wi-Fi networks from the server.
* @returns {Promise<Array<Object>>} A promise that resolves to an array of Wi-Fi access point objects.
* @throws {Error} Throws an error if the network request fails.
*/
export async function fetchWifiScan() {
const response = await fetch('/api/wifi/scan');
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return await response.json();
}
/**
* Sends a request to connect to a specific Wi-Fi network.
* @param {string} ssid The SSID of the network to connect to.
* @param {string} password The password for the network.
* @returns {Promise<Object>} A promise that resolves to the server's JSON response.
* @throws {Error} Throws an error if the connection request fails.
*/
export async function postWifiConnect(ssid, password) {
const response = await fetch('/api/setting', { // Updated URL
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid, password }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `Connection failed with status: ${response.status}`);
}
return await response.json();
}
/**
* Posts updated network settings (e.g., static IP, DHCP, AP mode) to the server.
* @param {Object} payload The settings object to be sent.
* @returns {Promise<Response>} A promise that resolves to the raw fetch response.
* @throws {Error} Throws an error if the request fails.
*/
export async function postNetworkSettings(payload) {
const response = await fetch('/api/setting', { // Updated URL
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `Failed to apply settings with status: ${response.status}`);
}
return response;
}
/**
* Posts the selected UART baud rate to the server.
* @param {string} baudrate The selected baud rate.
* @returns {Promise<Response>} A promise that resolves to the raw fetch response.
* @throws {Error} Throws an error if the request fails.
*/
export async function postBaudRateSetting(baudrate) {
const response = await fetch('/api/setting', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baudrate }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `Failed to apply baudrate with status: ${response.status}`);
}
return response;
}
/**
* Fetches the current network settings and Wi-Fi status from the server.
* @returns {Promise<Object>} A promise that resolves to an object containing the current settings.
* @throws {Error} Throws an error if the network request fails.
*/
export async function fetchSettings() {
const response = await fetch('/api/setting'); // Updated URL
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return await response.json();
}
/**
* Fetches the current status of the power control relays (12V and 5V).
* @returns {Promise<Object>} A promise that resolves to an object with the power status.
* @throws {Error} Throws an error if the network request fails.
*/
export async function fetchControlStatus() {
const response = await fetch('/api/control');
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return await response.json();
}
/**
* Sends a command to the server to control power functions (e.g., toggle relays, trigger reset).
* @param {Object} command The control command object.
* @returns {Promise<Response>} A promise that resolves to the raw fetch response.
* @throws {Error} Throws an error if the request fails.
*/
export async function postControlCommand(command) {
const response = await fetch('/api/control', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(command)
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response;
}

211
page/src/chart.js Normal file
View File

@@ -0,0 +1,211 @@
/**
* @file chart.js
* @description This module manages the Chart.js instances for visualizing sensor data.
* It handles initialization, theme updates, data updates, and resizing for the three separate charts.
*/
import { Chart, registerables } from 'chart.js';
import { powerChartCtx, voltageChartCtx, currentChartCtx, htmlEl, graphTabPane } from './dom.js';
// Register all necessary Chart.js components
Chart.register(...registerables);
// Store chart instances in an object
export const charts = {
power: null,
voltage: null,
current: null
};
const CHART_DATA_POINTS = 30; // Number of data points to display on the chart
/**
* Creates an array of empty labels for initial chart rendering.
* @returns {Array<string>} An array of empty strings.
*/
function initialLabels() {
return Array(CHART_DATA_POINTS).fill('');
}
/**
* Creates an array of null data points for initial chart rendering.
* @returns {Array<null>} An array of nulls.
*/
function initialData() {
return Array(CHART_DATA_POINTS).fill(null);
}
/**
* Creates a common configuration object for a single line chart.
* @param {string} title - The title of the chart (e.g., 'Power (W)').
* @returns {Object} A Chart.js options object.
*/
function createChartOptions(title) {
return {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'top' },
title: { display: true, text: title }
},
scales: {
x: { ticks: { autoSkipPadding: 10, maxRotation: 0, minRotation: 0 } },
y: { }
}
};
}
/**
* Initializes all three charts (Power, Voltage, Current).
* If chart instances already exist, they are destroyed and new ones are created.
*/
export function initCharts() {
// Destroy existing charts if they exist
for (const key in charts) {
if (charts[key]) {
charts[key].destroy();
}
}
// Create Power Chart
if (powerChartCtx) {
charts.power = new Chart(powerChartCtx, {
type: 'line',
data: {
labels: initialLabels(),
datasets: [
{ label: 'Power (W)', data: initialData(), borderWidth: 2, fill: false, tension: 0.2, pointRadius: 2 },
{ label: 'Avg Power', data: initialData(), borderWidth: 1.5, borderDash: [10, 5], fill: false, tension: 0, pointRadius: 0 }
]
},
options: createChartOptions('Power')
});
}
// Create Voltage Chart
if (voltageChartCtx) {
charts.voltage = new Chart(voltageChartCtx, {
type: 'line',
data: {
labels: initialLabels(),
datasets: [
{ label: 'Voltage (V)', data: initialData(), borderWidth: 2, fill: false, tension: 0.2, pointRadius: 2 },
{ label: 'Avg Voltage', data: initialData(), borderWidth: 1.5, borderDash: [10, 5], fill: false, tension: 0, pointRadius: 0 }
]
},
options: createChartOptions('Voltage')
});
}
// Create Current Chart
if (currentChartCtx) {
charts.current = new Chart(currentChartCtx, {
type: 'line',
data: {
labels: initialLabels(),
datasets: [
{ label: 'Current (A)', data: initialData(), borderWidth: 2, fill: false, tension: 0.2, pointRadius: 2 },
{ label: 'Avg Current', data: initialData(), borderWidth: 1.5, borderDash: [10, 5], fill: false, tension: 0, pointRadius: 0 }
]
},
options: createChartOptions('Current')
});
}
}
/**
* Applies a new theme (light or dark) to all charts.
* @param {string} themeName - The name of the theme to apply ('light' or 'dark').
*/
export function applyChartsTheme(themeName) {
const isDark = themeName === 'dark';
const gridColor = isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
const labelColor = isDark ? '#dee2e6' : '#212529';
const powerColor = getComputedStyle(htmlEl).getPropertyValue('--chart-power-color');
const voltageColor = getComputedStyle(htmlEl).getPropertyValue('--chart-voltage-color');
const currentColor = getComputedStyle(htmlEl).getPropertyValue('--chart-current-color');
const updateThemeForChart = (chart, color) => {
if (!chart) return;
chart.options.scales.x.grid.color = gridColor;
chart.options.scales.y.grid.color = gridColor;
chart.options.scales.x.ticks.color = labelColor;
chart.options.scales.y.ticks.color = labelColor;
chart.options.plugins.legend.labels.color = labelColor;
chart.options.plugins.title.color = labelColor;
chart.data.datasets[0].borderColor = color;
chart.data.datasets[1].borderColor = color;
chart.data.datasets[1].borderDash = [10, 5];
chart.update('none');
};
updateThemeForChart(charts.power, powerColor);
updateThemeForChart(charts.voltage, voltageColor);
updateThemeForChart(charts.current, currentColor);
}
/**
* Updates all charts with new sensor data.
* @param {Object} data - The new sensor data object from the WebSocket.
*/
export function updateCharts(data) {
const timeLabel = new Date(data.timestamp * 1000).toLocaleTimeString();
const updateSingleChart = (chart, value) => {
if (!chart) return;
// Shift old data
chart.data.labels.shift();
chart.data.datasets.forEach(dataset => dataset.data.shift());
// Push new data
chart.data.labels.push(timeLabel);
chart.data.datasets[0].data.push(value.toFixed(2));
// Calculate average and adjust Y-axis scale
const dataArray = chart.data.datasets[0].data.filter(v => v !== null).map(v => parseFloat(v));
if (dataArray.length > 0) {
const sum = dataArray.reduce((acc, val) => acc + val, 0);
const avg = (sum / dataArray.length).toFixed(2);
chart.data.datasets[1].data.push(avg);
// Adjust Y-axis scale for centering
const minVal = Math.min(...dataArray);
const maxVal = Math.max(...dataArray);
let padding = (maxVal - minVal) * 0.1; // 10% padding of the range
if (padding === 0) {
// If all values are the same, add 10% padding of the value itself, or a small default
padding = maxVal > 0 ? maxVal * 0.1 : 0.1;
}
chart.options.scales.y.min = Math.max(0, minVal - padding);
chart.options.scales.y.max = maxVal + padding;
} else {
chart.data.datasets[1].data.push(null);
}
// Only update the chart if the tab is visible
if (graphTabPane.classList.contains('show')) {
chart.update('none');
}
};
updateSingleChart(charts.power, data.power);
updateSingleChart(charts.voltage, data.voltage);
updateSingleChart(charts.current, data.current);
}
/**
* Resizes all chart canvases. This is typically called on window resize events.
*/
export function resizeCharts() {
for (const key in charts) {
if (charts[key]) {
charts[key].resize();
}
}
}

78
page/src/dom.js Normal file
View File

@@ -0,0 +1,78 @@
/**
* @file dom.js
* @description This module selects and exports all necessary DOM elements for the application.
* This centralizes DOM access, making it easier to manage and modify element selectors.
*/
// --- Theme Elements ---
export const themeToggle = document.getElementById('theme-toggle');
export const themeIcon = document.getElementById('theme-icon');
export const htmlEl = document.documentElement;
// --- Control & Status Elements ---
export const mainPowerToggle = document.getElementById('main-power-toggle');
export const usbPowerToggle = document.getElementById('usb-power-toggle');
export const resetButton = document.getElementById('reset-button');
export const powerActionButton = document.getElementById('power-action-button');
export const voltageDisplay = document.getElementById('voltage-display');
export const currentDisplay = document.getElementById('current-display');
export const powerDisplay = document.getElementById('power-display');
export const uptimeDisplay = document.getElementById('uptime-display');
// --- Terminal Elements ---
export const terminalContainer = document.getElementById('terminal');
export const clearButton = document.getElementById('clear-button');
export const downloadButton = document.getElementById('download-button');
// --- Chart & Graph Elements ---
export const graphTabPane = document.getElementById('graph-tab-pane');
export const powerChartCtx = document.getElementById('powerChart')?.getContext('2d');
export const voltageChartCtx = document.getElementById('voltageChart')?.getContext('2d');
export const currentChartCtx = document.getElementById('currentChart')?.getContext('2d');
// --- WebSocket Status Elements ---
export const websocketStatus = document.getElementById('websocket-status');
export const websocketIcon = document.getElementById('websocket-icon');
export const websocketStatusText = document.getElementById('websocket-status-text');
// --- Header Wi-Fi Status ---
export const wifiStatus = document.getElementById('wifi-status');
export const wifiIcon = document.getElementById('wifi-icon');
export const wifiSsidStatus = document.getElementById('wifi-ssid-status');
// --- Settings Modal & General ---
export const settingsButton = document.getElementById('settings-button');
export const settingsModal = document.getElementById('settingsModal');
// --- Wi-Fi Settings Elements ---
export const wifiApList = document.getElementById('wifi-ap-list');
export const scanWifiButton = document.getElementById('scan-wifi-button');
export const currentWifiSsid = document.getElementById('current-wifi-ssid');
export const currentWifiIp = document.getElementById('current-wifi-ip');
// --- Wi-Fi Connection Modal Elements ---
export const wifiModalEl = document.getElementById('wifiModal');
export const wifiSsidConnectInput = document.getElementById('wifi-ssid-connect');
export const wifiPasswordConnectInput = document.getElementById('wifi-password-connect');
export const wifiConnectButton = document.getElementById('wifi-connect-button');
// --- Static IP Config Elements ---
export const staticIpToggle = document.getElementById('static-ip-toggle');
export const staticIpConfig = document.getElementById('static-ip-config');
export const networkApplyButton = document.getElementById('network-apply-button');
export const staticIpInput = document.getElementById('ip-address');
export const staticGatewayInput = document.getElementById('gateway');
export const staticNetmaskInput = document.getElementById('netmask');
export const dns1Input = document.getElementById('dns1');
export const dns2Input = document.getElementById('dns2');
// --- AP Mode Config Elements ---
export const apModeToggle = document.getElementById('ap-mode-toggle');
export const apModeConfig = document.getElementById('ap-mode-config');
export const apModeApplyButton = document.getElementById('ap-mode-apply-button');
export const apSsidInput = document.getElementById('ap-ssid');
export const apPasswordInput = document.getElementById('ap-password');
// --- Device Settings Elements ---
export const baudRateSelect = document.getElementById('baud-rate-select');
export const baudRateApplyButton = document.getElementById('baud-rate-apply-button');

93
page/src/events.js Normal file
View File

@@ -0,0 +1,93 @@
/**
* @file events.js
* @description This module sets up all the event listeners for the application.
* It connects user interactions (like clicks and toggles) to the corresponding
* functions in other modules (UI, API, etc.).
*/
import * as dom from './dom.js';
import * as api from './api.js';
import * as ui from './ui.js';
import { clearTerminal, fitTerminal, downloadTerminalOutput } from './terminal.js';
import { debounce, isMobile } from './utils.js';
// A flag to track if charts have been initialized
let chartsInitialized = false;
/**
* Sets up all event listeners for the application's interactive elements.
*/
export function setupEventListeners() {
// --- Theme Toggle ---
dom.themeToggle.addEventListener('change', () => {
const newTheme = dom.themeToggle.checked ? 'dark' : 'light';
localStorage.setItem('theme', newTheme);
ui.applyTheme(newTheme);
});
// --- Terminal Controls ---
dom.clearButton.addEventListener('click', clearTerminal);
dom.downloadButton.addEventListener('click', downloadTerminalOutput);
// --- Power Controls ---
dom.mainPowerToggle.addEventListener('change', () => api.postControlCommand({'load_12v_on': dom.mainPowerToggle.checked}).then(ui.updateControlStatus));
dom.usbPowerToggle.addEventListener('change', () => api.postControlCommand({'load_5v_on': dom.usbPowerToggle.checked}).then(ui.updateControlStatus));
dom.resetButton.addEventListener('click', () => api.postControlCommand({'reset_trigger': true}));
dom.powerActionButton.addEventListener('click', () => api.postControlCommand({'power_trigger': true}));
// --- Settings Modal Controls ---
dom.scanWifiButton.addEventListener('click', ui.scanForWifi);
dom.wifiConnectButton.addEventListener('click', ui.connectToWifi);
dom.networkApplyButton.addEventListener('click', ui.applyNetworkSettings);
dom.apModeApplyButton.addEventListener('click', ui.applyApModeSettings);
dom.baudRateApplyButton.addEventListener('click', ui.applyBaudRateSettings);
// --- Settings Modal Toggles (for showing/hiding sections) ---
dom.apModeToggle.addEventListener('change', () => {
dom.apModeConfig.style.display = dom.apModeToggle.checked ? 'block' : 'none';
});
dom.staticIpToggle.addEventListener('change', () => {
dom.staticIpConfig.style.display = dom.staticIpToggle.checked ? 'block' : 'none';
});
// --- General App Listeners ---
dom.settingsButton.addEventListener('click', ui.initializeSettings);
// --- Accessibility: Remove focus from modal elements before hiding ---
const blurActiveElement = () => {
if (document.activeElement && typeof document.activeElement.blur === 'function') {
document.activeElement.blur();
}
};
dom.settingsModal.addEventListener('hide.bs.modal', blurActiveElement);
dom.wifiModalEl.addEventListener('hide.bs.modal', blurActiveElement);
// --- Bootstrap Tab Events ---
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(tabEl => {
tabEl.addEventListener('shown.bs.tab', async (event) => {
const tabId = event.target.getAttribute('data-bs-target');
if (tabId === '#graph-tab-pane') {
// Dynamically import the chart module only when the tab is shown
const chartModule = await import('./chart.js');
if (!chartsInitialized) {
chartModule.initCharts();
chartsInitialized = true;
} else {
chartModule.resizeCharts();
}
} else if (tabId === '#terminal-tab-pane') {
// Fit the terminal when its tab is shown, especially for mobile.
if (isMobile()) {
fitTerminal();
}
}
});
});
// --- Window Resize Event ---
// Debounced to avoid excessive calls during resizing.
window.addEventListener('resize', debounce(ui.handleResize, 150));
}

109
page/src/main.js Normal file
View File

@@ -0,0 +1,109 @@
/**
* @file main.js
* @description The main entry point for the web application.
* This file imports all necessary modules, sets up the application structure,
* initializes components, and establishes the WebSocket connection.
*/
// --- Stylesheets ---
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.css';
import './style.css';
// --- Module Imports ---
import { initWebSocket } from './websocket.js';
import { setupTerminal, term } from './terminal.js';
import {
applyTheme,
initUI,
updateControlStatus,
updateSensorUI,
updateWifiStatusUI,
updateWebsocketStatus
} from './ui.js';
import { setupEventListeners } from './events.js';
// --- WebSocket Event Handlers ---
/**
* Callback function for when the WebSocket connection is successfully opened.
* Updates the UI to show an 'Online' status and fetches the initial control status.
*/
function onWsOpen() {
updateWebsocketStatus(true);
if (term) {
term.write('\x1b[32mConnected to WebSocket Server\x1b[0m\r\n');
}
updateControlStatus();
}
/**
* Callback function for when the WebSocket connection is closed.
* Updates the UI to show an 'Offline' status and attempts to reconnect after a delay.
*/
function onWsClose() {
updateWebsocketStatus(false);
if (term) {
term.write('\r\n\x1b[31mConnection closed. Reconnecting...\x1b[0m\r\n');
}
// Attempt to re-establish the connection after 2 seconds
setTimeout(initialize, 2000);
}
/**
* Callback function for when a message is received from the WebSocket server.
* It handles both JSON messages (for sensor and status updates) and binary data (for the terminal).
* @param {MessageEvent} event - The WebSocket message event.
*/
function onWsMessage(event) {
if (typeof event.data === 'string') {
try {
const message = JSON.parse(event.data);
if (message.type === 'sensor_data') {
updateSensorUI(message);
} else if (message.type === 'wifi_status') {
updateWifiStatusUI(message);
}
} catch (e) {
// Ignore non-JSON string messages
}
} else if (term && event.data instanceof ArrayBuffer) {
// Write raw UART data to the terminal
const data = new Uint8Array(event.data);
term.write(data);
}
}
// --- Application Initialization ---
/**
* Initializes the entire application.
* This function sets up the UI, theme, terminal, chart, WebSocket connection, and event listeners.
*/
function initialize() {
// Initialize basic UI components
initUI();
// Set up the interactive components first
setupTerminal();
// Apply the saved theme or detect the user's preferred theme.
// This must be done AFTER the chart and terminal are initialized.
const savedTheme = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
applyTheme(savedTheme);
// Establish the WebSocket connection with the defined handlers
initWebSocket({
onOpen: onWsOpen,
onClose: onWsClose,
onMessage: onWsMessage
});
// Attach all event listeners to the DOM elements
setupEventListeners();
}
// --- Start Application ---
// Wait for the DOM to be fully loaded before initializing the application.
document.addEventListener('DOMContentLoaded', initialize);

153
page/src/style.css Normal file
View File

@@ -0,0 +1,153 @@
:root {
--bs-body-font-family: 'Courier New', Courier, monospace;
--chart-power-color: #007bff;
--chart-voltage-color: #28a745;
--chart-current-color: #ffc107;
}
[data-bs-theme="dark"] {
--chart-power-color: #569cd6;
--chart-voltage-color: #4ec9b0;
--chart-current-color: #dcdcaa;
}
body, .card, .modal-content, .list-group-item, .nav-tabs .nav-link {
transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out, border-color 0.2s ease-in-out;
}
html, body {
height: 100%;
}
body {
display: flex;
flex-direction: column;
padding-top: 1rem;
}
main {
flex-grow: 1;
padding-bottom: 2rem;
}
#terminal-tab-pane .card-body {
text-align: center;
}
#terminal-container {
padding: 0.5rem;
background-color: var(--bs-body-bg);
overflow: auto;
}
#terminal-wrapper {
display: inline-block;
text-align: left;
/* Removed height: 100% to allow shrink-wrapping */
}
#graph-tab-pane .card-body {
display: flex;
flex-direction: column;
justify-content: center; /* Vertically center the chart content */
}
.xterm .xterm-viewport {
background-color: transparent !important;
}
.xterm-viewport::-webkit-scrollbar {
width: 12px;
}
.xterm-viewport::-webkit-scrollbar-track {
background: var(--bs-tertiary-bg);
}
.xterm-viewport::-webkit-scrollbar-thumb {
background-color: var(--bs-secondary-bg);
border-radius: 10px;
border: 3px solid var(--bs-tertiary-bg);
}
.xterm-viewport::-webkit-scrollbar-thumb:hover {
background-color: var(--bs-secondary-color);
}
.xterm-viewport {
scrollbar-width: thin;
scrollbar-color: var(--bs-secondary-bg) var(--bs-tertiary-bg);
}
.chart-canvas {
height: 30rem !important;
}
.control-wrapper {
min-height: 38px; /* Match button height for alignment */
display: flex;
align-items: center;
justify-content: flex-end;
}
.control-wrapper .form-switch {
display: flex;
align-items: center;
height: 100%;
}
footer {
flex-shrink: 0;
font-size: 0.9em;
}
footer a {
text-decoration: none;
}
.wifi-ap-row {
cursor: pointer;
}
/* Mobile Optimizations */
@media (max-width: 767.98px) {
.main-header {
flex-direction: column;
}
.header-controls {
width: 100%;
justify-content: space-between !important;
margin-top: 0.5rem;
}
.main-header h1 {
font-size: 1.75rem;
}
.control-list-item {
flex-direction: column;
align-items: flex-start !important;
}
.control-list-item > .control-wrapper {
margin-top: 0.5rem;
width: 100%;
display: flex;
justify-content: space-between;
}
.control-list-item > .control-wrapper > .btn {
flex-grow: 1;
}
#terminal-wrapper, #terminal-container {
width: 100%;
/* Removed height: 100% */
}
#terminal-tab-pane .card-body {
padding: 0;
}
}

135
page/src/terminal.js Normal file
View File

@@ -0,0 +1,135 @@
/**
* @file terminal.js
* @description This module manages the Xterm.js terminal instance, including setup,
* theme handling, and data communication with the WebSocket.
*/
import { Terminal } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css';
import { FitAddon } from '@xterm/addon-fit';
import { terminalContainer } from './dom.js';
import { isMobile } from './utils.js';
import { websocket, sendWebsocketMessage } from './websocket.js';
// Exported terminal instance and addon for global access
export let term;
export let fitAddon;
// Theme definitions for the terminal
const lightTheme = {
background: 'transparent',
foreground: '#212529',
cursor: '#212529',
selectionBackground: 'rgba(0,0,0,0.1)'
};
const darkTheme = {
background: 'transparent',
foreground: '#dee2e6',
cursor: '#dee2e6',
selectionBackground: 'rgba(255,255,255,0.1)'
};
/**
* Initializes the Xterm.js terminal, loads addons, and attaches it to the DOM.
* Sets up the initial size and the data handler for sending input to the WebSocket.
*/
export function setupTerminal() {
// Ensure the container is empty before creating a new terminal
while (terminalContainer.firstChild) {
terminalContainer.removeChild(terminalContainer.firstChild);
}
fitAddon = new FitAddon();
term = new Terminal({ convertEol: true, cursorBlink: true });
term.loadAddon(fitAddon);
term.open(terminalContainer);
// Adjust terminal size based on device type
if (isMobile()) {
fitAddon.fit();
} else {
term.resize(80, 24); // Default size for desktop
}
// Handle user input and send it over the WebSocket
term.onData(data => {
sendWebsocketMessage(data);
});
}
/**
* Applies a new theme (light or dark) to the terminal.
* @param {string} themeName - The name of the theme to apply ('light' or 'dark').
*/
export function applyTerminalTheme(themeName) {
if (!term) return;
const isDark = themeName === 'dark';
term.options.theme = isDark ? darkTheme : lightTheme;
}
/**
* Clears the terminal screen.
*/
export function clearTerminal() {
if (term) {
term.clear();
}
}
/**
* Fits the terminal to the size of its container element.
* Useful for responsive adjustments on window resize.
*/
export function fitTerminal() {
if (fitAddon) {
fitAddon.fit();
}
}
/**
* Gathers all text from the terminal buffer and downloads it as a .log file.
*/
export function downloadTerminalOutput() {
if (!term) {
console.error("Terminal is not initialized.");
return;
}
const buffer = term.buffer.active;
let fullText = '';
// Iterate through the buffer to get all lines
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i);
if (line) {
fullText += line.translateToString() + '\n';
}
}
if (fullText.trim() === '') {
console.warn("Terminal is empty, nothing to download.");
// Optionally, provide user feedback here (e.g., a toast notification)
return;
}
// Create a blob from the text content
const blob = new Blob([fullText], { type: 'text/plain;charset=utf-8' });
// Create a link element to trigger the download
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
// Generate a filename with a timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
link.download = `odroid-log-${timestamp}.log`;
link.href = url;
// Append to the document, click, and then remove
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the object URL
URL.revokeObjectURL(url);
}

346
page/src/ui.js Normal file
View File

@@ -0,0 +1,346 @@
/**
* @file ui.js
* @description This module manages all UI interactions and updates.
* It bridges the gap between backend data (from API and WebSockets) and the user-facing components,
* handling everything from theme changes to dynamic content updates.
*/
import * as bootstrap from 'bootstrap';
import * as dom from './dom.js';
import * as api from './api.js';
import { formatUptime, isMobile } from './utils.js';
import { applyTerminalTheme, fitTerminal } from './terminal.js';
import { applyChartsTheme, resizeCharts, updateCharts } from './chart.js';
// Instance of the Bootstrap Modal for Wi-Fi connection
let wifiModal;
/**
* Initializes the UI components, such as the Bootstrap modal.
*/
export function initUI() {
wifiModal = new bootstrap.Modal(dom.wifiModalEl);
}
/**
* Applies the selected theme (light or dark) to the entire application.
* @param {string} themeName - The name of the theme ('light' or 'dark').
*/
export function applyTheme(themeName) {
const isDark = themeName === 'dark';
dom.htmlEl.setAttribute('data-bs-theme', themeName);
dom.themeIcon.className = isDark ? 'bi bi-moon-stars-fill' : 'bi bi-sun-fill';
dom.themeToggle.checked = isDark;
applyTerminalTheme(themeName);
applyChartsTheme(themeName);
}
/**
* Updates the UI with the latest sensor data.
* @param {Object} data - The sensor data object from the WebSocket.
*/
export function updateSensorUI(data) {
dom.voltageDisplay.textContent = `${data.voltage.toFixed(2)} V`;
dom.currentDisplay.textContent = `${data.current.toFixed(2)} A`;
dom.powerDisplay.textContent = `${data.power.toFixed(2)} W`;
if (data.uptime_sec !== undefined) {
dom.uptimeDisplay.textContent = formatUptime(data.uptime_sec);
}
updateCharts(data);
}
/**
* Updates the Wi-Fi status indicator in the header.
* @param {Object} data - The Wi-Fi status object from the WebSocket.
*/
export function updateWifiStatusUI(data) {
if (data.connected) {
dom.wifiSsidStatus.textContent = data.ssid;
dom.wifiStatus.title = `Signal Strength: ${data.rssi} dBm`;
let iconClass = 'bi me-2 ';
if (data.rssi >= -60) iconClass += 'bi-wifi';
else if (data.rssi >= -75) iconClass += 'bi-wifi-2';
else iconClass += 'bi-wifi-1';
dom.wifiIcon.className = iconClass;
dom.wifiStatus.classList.replace('text-muted', 'text-success');
dom.wifiStatus.classList.remove('text-danger');
} else {
dom.wifiSsidStatus.textContent = 'Disconnected';
dom.wifiStatus.title = '';
dom.wifiIcon.className = 'bi bi-wifi-off me-2';
dom.wifiStatus.classList.replace('text-success', 'text-muted');
dom.wifiStatus.classList.remove('text-danger');
}
}
/**
* Initiates a Wi-Fi scan and updates the settings modal with the results.
*/
export async function scanForWifi() {
dom.scanWifiButton.disabled = true;
dom.scanWifiButton.innerHTML = `<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Scanning...`;
dom.wifiApList.innerHTML = '<tr><td colspan="3" class="text-center">Scanning for networks...</td></tr>';
try {
const apRecords = await api.fetchWifiScan();
dom.wifiApList.innerHTML = ''; // Clear loading message
if (apRecords.length === 0) {
dom.wifiApList.innerHTML = '<tr><td colspan="3" class="text-center">No networks found.</td></tr>';
} else {
apRecords.forEach(ap => {
const row = document.createElement('tr');
row.className = 'wifi-ap-row';
let rssiIcon;
if (ap.rssi >= -60) rssiIcon = 'bi-wifi';
else if (ap.rssi >= -75) rssiIcon = 'bi-wifi-2';
else rssiIcon = 'bi-wifi-1';
row.innerHTML = `
<td>${ap.ssid}</td>
<td class="text-center"><i class="bi ${rssiIcon}"></i></td>
<td>${ap.authmode}</td>
`;
row.addEventListener('click', () => {
dom.wifiSsidConnectInput.value = ap.ssid;
dom.wifiPasswordConnectInput.value = '';
wifiModal.show();
dom.wifiModalEl.addEventListener('shown.bs.modal', () => {
dom.wifiPasswordConnectInput.focus();
}, { once: true });
});
dom.wifiApList.appendChild(row);
});
}
} catch (error) {
console.error('Error scanning for Wi-Fi:', error);
dom.wifiApList.innerHTML = `<tr><td colspan="3" class="text-center text-danger">Scan failed: ${error.message}</td></tr>`;
} finally {
dom.scanWifiButton.disabled = false;
dom.scanWifiButton.innerHTML = 'Scan';
}
}
/**
* Handles the Wi-Fi connection process, sending credentials to the server.
*/
export async function connectToWifi() {
const ssid = dom.wifiSsidConnectInput.value;
const password = dom.wifiPasswordConnectInput.value;
if (!ssid) return;
dom.wifiConnectButton.disabled = true;
dom.wifiConnectButton.innerHTML = `<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Connecting...`;
try {
const result = await api.postWifiConnect(ssid, password);
if (result.status === 'connection_initiated') {
wifiModal.hide();
setTimeout(() => {
alert(`Connection to "${ssid}" initiated. The device will try to reconnect. Please check the Wi-Fi status icon.`);
}, 500);
} else {
throw new Error(result.message || 'Unknown server response.');
}
} catch (error) {
console.error('Error connecting to Wi-Fi:', error);
alert(`Failed to connect: ${error.message}`);
} finally {
dom.wifiConnectButton.disabled = false;
dom.wifiConnectButton.innerHTML = 'Connect';
}
}
/**
* Applies network settings (Static IP or DHCP) by sending the configuration to the server.
*/
export async function applyNetworkSettings() {
const useStatic = dom.staticIpToggle.checked;
let payload;
dom.networkApplyButton.disabled = true;
dom.networkApplyButton.innerHTML = `<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Applying...`;
if (useStatic) {
const ip = dom.staticIpInput.value;
const gateway = dom.staticGatewayInput.value;
const subnet = dom.staticNetmaskInput.value;
const dns1 = dom.dns1Input.value;
const dns2 = dom.dns2Input.value;
if (!ip || !gateway || !subnet || !dns1) {
alert('For static IP, you must provide IP Address, Gateway, Netmask, and DNS Server.');
dom.networkApplyButton.disabled = false;
dom.networkApplyButton.innerHTML = 'Apply';
return;
}
payload = { net_type: 'static', ip, gateway, subnet, dns1 };
if (dns2) payload.dns2 = dns2;
} else {
payload = { net_type: 'dhcp' };
}
try {
await api.postNetworkSettings(payload);
alert('Network settings applied. Reconnect to the network for changes to take effect.');
initializeSettings();
} catch (error) {
console.error('Error applying network settings:', error);
alert(`Failed to apply settings: ${error.message}`);
} finally {
dom.networkApplyButton.disabled = false;
dom.networkApplyButton.innerHTML = 'Apply';
}
}
/**
* Applies AP Mode settings (AP+STA or STA) by sending the configuration to the server.
*/
export async function applyApModeSettings() {
const mode = dom.apModeToggle.checked ? 'apsta' : 'sta';
let payload = { mode };
dom.apModeApplyButton.disabled = true;
dom.apModeApplyButton.innerHTML = `<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Applying...`;
if (mode === 'apsta') {
const ap_ssid = dom.apSsidInput.value;
const ap_password = dom.apPasswordInput.value;
if (!ap_ssid) {
alert('AP SSID cannot be empty when enabling APSTA mode.');
dom.apModeApplyButton.disabled = false;
dom.apModeApplyButton.innerHTML = 'Apply';
return;
}
payload.ap_ssid = ap_ssid;
if (ap_password) {
payload.ap_password = ap_password;
}
}
try {
await api.postNetworkSettings(payload); // Reuses the same API endpoint
alert(`Successfully switched mode to ${mode}. The device will now reconfigure.`);
initializeSettings();
} catch (error) {
console.error('Error switching Wi-Fi mode:', error);
alert(`Failed to switch mode: ${error.message}`);
} finally {
dom.apModeApplyButton.disabled = false;
dom.apModeApplyButton.innerHTML = 'Apply';
}
}
/**
* Applies the selected UART baud rate by sending it to the server.
*/
export async function applyBaudRateSettings() {
const baudrate = dom.baudRateSelect.value;
dom.baudRateApplyButton.disabled = true;
dom.baudRateApplyButton.innerHTML = `<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Applying...`;
try {
await api.postBaudRateSetting(baudrate);
} catch (error) {
console.error('Error applying baud rate:', error);
} finally {
dom.baudRateApplyButton.disabled = false;
dom.baudRateApplyButton.innerHTML = 'Apply';
}
}
/**
* Fetches and displays the current network and device settings in the settings modal.
*/
export async function initializeSettings() {
try {
const data = await api.fetchSettings();
// Wi-Fi Connection Status
if (data.connected) {
dom.currentWifiSsid.textContent = data.ssid;
dom.currentWifiIp.textContent = `IP Address: ${data.ip ? data.ip.ip : 'N/A'}`;
} else {
dom.currentWifiSsid.textContent = 'Not Connected';
dom.currentWifiIp.textContent = 'IP Address: -';
}
// Network (Static/DHCP) Settings
if (data.ip) {
dom.staticIpInput.value = data.ip.ip || '';
dom.staticGatewayInput.value = data.ip.gateway || '';
dom.staticNetmaskInput.value = data.ip.subnet || '';
dom.dns1Input.value = data.ip.dns1 || '';
dom.dns2Input.value = data.ip.dns2 || '';
}
dom.staticIpToggle.checked = data.net_type === 'static';
dom.staticIpConfig.style.display = dom.staticIpToggle.checked ? 'block' : 'none';
// AP Mode Settings
dom.apModeToggle.checked = data.mode === 'apsta';
dom.apModeConfig.style.display = dom.apModeToggle.checked ? 'block' : 'none';
dom.apSsidInput.value = ''; // For security, don't pre-fill
dom.apPasswordInput.value = '';
// Device Settings
if (data.baudrate) {
dom.baudRateSelect.value = data.baudrate;
}
} catch (error) {
console.error('Error initializing settings:', error);
// Reset fields on error
dom.currentWifiSsid.textContent = 'Status Unknown';
dom.currentWifiIp.textContent = 'IP Address: -';
dom.staticIpToggle.checked = false;
dom.staticIpConfig.style.display = 'none';
dom.apModeToggle.checked = false;
dom.apModeConfig.style.display = 'none';
}
}
/**
* Fetches and updates the status of the power control toggles.
*/
export async function updateControlStatus() {
try {
const status = await api.fetchControlStatus();
dom.mainPowerToggle.checked = status.load_12v_on;
dom.usbPowerToggle.checked = status.load_5v_on;
} catch (error) {
console.error('Error fetching control status:', error);
}
}
/**
* Handles window resize events to make components responsive.
*/
export function handleResize() {
if (isMobile()) {
fitTerminal();
}
resizeCharts();
}
/**
* Updates the WebSocket connection status indicator in the header.
* @param {boolean} isConnected - True if the WebSocket is connected, false otherwise.
*/
export function updateWebsocketStatus(isConnected) {
if (isConnected) {
dom.websocketStatusText.textContent = 'Online';
dom.websocketIcon.className = 'bi bi-check-circle-fill me-2';
dom.websocketStatus.classList.remove('text-danger');
dom.websocketStatus.classList.add('text-success');
} else {
dom.websocketStatusText.textContent = 'Offline';
dom.websocketIcon.className = 'bi bi-x-circle-fill me-2';
dom.websocketStatus.classList.remove('text-success');
dom.websocketStatus.classList.add('text-danger');
}
}

44
page/src/utils.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* @file utils.js
* @description Provides utility functions used throughout the application.
*/
/**
* Creates a debounced function that delays invoking `func` until after `delay` milliseconds
* have elapsed since the last time the debounced function was invoked.
* @param {Function} func The function to debounce.
* @param {number} delay The number of milliseconds to delay.
* @returns {Function} Returns the new debounced function.
*/
export function debounce(func, delay) {
let timeout;
return function (...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), delay);
};
}
/**
* Formats a duration in total seconds into a human-readable string (e.g., "1d 02:30:15").
* @param {number} totalSeconds The total seconds to format.
* @returns {string} The formatted uptime string.
*/
export function formatUptime(totalSeconds) {
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad = (num) => String(num).padStart(2, '0');
const timeString = `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
return days > 0 ? `${days}d ${timeString}` : timeString;
}
/**
* Checks if the current device is likely a mobile device based on screen width.
* @returns {boolean} True if the window width is less than or equal to 767.98px, false otherwise.
*/
export function isMobile() {
// The 767.98px breakpoint is typically used by Bootstrap for medium (md) devices.
return window.innerWidth <= 767.98;
}

41
page/src/websocket.js Normal file
View File

@@ -0,0 +1,41 @@
/**
* @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.
*/
// The WebSocket instance, exported for potential direct access if needed.
export let websocket;
// The WebSocket server address, derived from the current page's hostname.
const gateway = `ws://${window.location.hostname}/ws`;
/**
* 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.
*/
export function initWebSocket({ onOpen, onClose, onMessage }) {
console.log(`Trying to open a WebSocket connection to ${gateway}...`);
websocket = new WebSocket(gateway);
// 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;
}
/**
* Sends data over the WebSocket connection if it is open.
* @param {string | ArrayBuffer} data - The data to send to the server.
*/
export function sendWebsocketMessage(data) {
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(data);
}
}

10
page/vite.config.js Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import { viteSingleFile } from 'vite-plugin-singlefile';
import viteCompression from 'vite-plugin-compression';
export default defineConfig({
plugins: [
viteSingleFile(),
viteCompression(),
],
});