15 Commits

Author SHA1 Message Date
55b5296d16 csv_2_plot: update y-axis gridline colors
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-09 18:19:26 +09:00
a8faa6a441 csv_2_plot: improve plot configuration and add average voltage/interval calculations
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-09 18:19:26 +09:00
c7188df159 logger: save time on UTC
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-09 16:49:12 +09:00
ce40257fea logger: align output format with main.js and csv_2_plot expectations
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-09 16:31:30 +09:00
649f05d330 edit plot title
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-09 16:26:16 +09:00
7896dddd1d displays the time according to the user's time zone.
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-09 16:24:13 +09:00
aa4012f981 change CSV file name format, organize CSV data
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-09 16:07:00 +09:00
af0d704e2e update readme
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-09 15:05:23 +09:00
a5658e3cf3 csv_2_plot: add option --source, fix y axis scale
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-09 14:59:58 +09:00
9923365184 add data record, download csv
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-09 14:52:07 +09:00
8ba4a179db change time units to milliseconds
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-09 14:36:24 +09:00
a1255e8304 add config period
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-09 14:23:27 +09:00
a75ec53d23 Merge pull request #1 from hardkernel/master
.
2025-12-08 10:22:48 +09:00
Hardkernel Co., Ltd.
11e3e126b0 Merge pull request #2 from shinys000114/master
Improved AP reconnection logic in STA mode
2025-12-08 10:14:32 +09:00
Hardkernel Co., Ltd.
d04ac35126 Merge pull request #1 from shinys000114/master
Update schematic, Add example script
2025-11-19 16:39:01 +09:00
19 changed files with 341 additions and 61 deletions

View File

@@ -6,6 +6,11 @@ Based on this script, you can monitor power consumption and implement graph plot
### Install Python Virtual Environment
```shell
git clone https://github.com/hardkernel/odroid-powermate.git
cd odroid-powermate/example/logger
```
```shell
sudo apt install virtualenv
virtualenv venv
@@ -35,7 +40,7 @@ python3 logger.py -u admin -p password -o test.csv 192.168.30.5
#### Plot data
```shell
python3 csv_2_plot.py test.csv plot.png [--type power voltage current]
python3 csv_2_plot.py test.csv plot.png [--type power voltage current] [--source vin main usb]
```
![plot.png](plot.png)

View File

@@ -3,9 +3,11 @@ import argparse
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import pandas as pd
from dateutil.tz import gettz
from matplotlib.ticker import MultipleLocator
def plot_power_data(csv_path, output_path, plot_types):
def plot_power_data(csv_path, output_path, plot_types, sources):
"""
Reads power data from a CSV file and generates a plot image.
@@ -14,12 +16,19 @@ def plot_power_data(csv_path, output_path, plot_types):
output_path (str): The path to save the output plot image.
plot_types (list): A list of strings indicating which plots to generate
(e.g., ['power', 'voltage', 'current']).
sources (list): A list of strings indicating which power sources to plot
(e.g., ['vin', 'main', 'usb']).
"""
try:
# Read the CSV file into a pandas DataFrame
# The 'timestamp' column is parsed as dates
df = pd.read_csv(csv_path, parse_dates=['timestamp'])
print(f"Successfully loaded {len(df)} records from '{csv_path}'")
# --- Timezone Conversion ---
local_tz = gettz()
df['timestamp'] = df['timestamp'].dt.tz_convert(local_tz)
print(f"Timestamp converted to local timezone: {local_tz}")
except FileNotFoundError:
print(f"Error: The file '{csv_path}' was not found.")
return
@@ -27,57 +36,134 @@ def plot_power_data(csv_path, output_path, plot_types):
print(f"An error occurred while reading the CSV file: {e}")
return
# --- Calculate Average Interval ---
avg_interval_ms = 0
if len(df) > 1:
avg_interval = df['timestamp'].diff().mean()
avg_interval_ms = avg_interval.total_seconds() * 1000
# --- Calculate Average Voltages ---
avg_voltages = {}
for source in sources:
voltage_col = f'{source}_voltage'
if voltage_col in df.columns:
avg_voltages[source] = df[voltage_col].mean()
# --- Plotting Configuration ---
plot_configs = {
'power': {'title': 'Power Consumption', 'ylabel': 'Power (W)',
'cols': ['vin_power', 'main_power', 'usb_power']},
'voltage': {'title': 'Voltage', 'ylabel': 'Voltage (V)',
'cols': ['vin_voltage', 'main_voltage', 'usb_voltage']},
'current': {'title': 'Current', 'ylabel': 'Current (A)', 'cols': ['vin_current', 'main_current', 'usb_current']}
scale_config = {
'power': {'steps': [5, 20, 50, 160]},
'voltage': {'steps': [5, 10, 15, 25]},
'current': {'steps': [1, 2.5, 5, 10]}
}
channel_labels = ['VIN', 'MAIN', 'USB']
channel_colors = ['red', 'green', 'blue']
plot_configs = {
'power': {'title': 'Power Consumption', 'ylabel': 'Power (W)', 'cols': [f'{s}_power' for s in sources]},
'voltage': {'title': 'Voltage', 'ylabel': 'Voltage (V)', 'cols': [f'{s}_voltage' for s in sources]},
'current': {'title': 'Current', 'ylabel': 'Current (A)', 'cols': [f'{s}_current' for s in sources]}
}
channel_labels = [s.upper() for s in sources]
color_map = {'vin': 'red', 'main': 'green', 'usb': 'blue'}
channel_colors = [color_map[s] for s in sources]
num_plots = len(plot_types)
if num_plots == 0:
print("No plot types selected. Exiting.")
return
# Create a figure and a set of subplots based on the number of selected plot types.
# sharex=True makes all subplots share the same x-axis (time)
# squeeze=False ensures that 'axes' is always a 2D array, even if num_plots is 1.
fig, axes = plt.subplots(num_plots, 1, figsize=(15, 6 * num_plots), sharex=True, squeeze=False)
axes = axes.flatten() # Flatten the 2D array to 1D for easier iteration
fig, axes = plt.subplots(num_plots, 1, figsize=(15, 9 * num_plots), sharex=True, squeeze=False)
axes = axes.flatten()
# --- Loop through selected plot types and generate plots ---
for i, plot_type in enumerate(plot_types):
ax = axes[i]
config = plot_configs[plot_type]
max_data_value = 0
for j, col_name in enumerate(config['cols']):
ax.plot(df['timestamp'], df[col_name], label=channel_labels[j], color=channel_colors[j])
if col_name in df.columns:
ax.plot(df['timestamp'], df[col_name], label=channel_labels[j], color=channel_colors[j], zorder=2)
max_col_value = df[col_name].max()
if max_col_value > max_data_value:
max_data_value = max_col_value
else:
print(f"Warning: Column '{col_name}' not found in CSV. Skipping.")
# --- Dynamic Y-axis Scaling ---
ax.set_ylim(bottom=0)
if plot_type in scale_config:
steps = scale_config[plot_type]['steps']
new_max = next((step for step in steps if step >= max_data_value), steps[-1])
ax.set_ylim(top=new_max)
ax.set_title(config['title'])
ax.set_ylabel(config['ylabel'])
ax.legend()
ax.grid(True, which='both', linestyle='--', linewidth=0.5)
# --- Grid and Tick Configuration ---
y_min, y_max = ax.get_ylim()
# Keep the dynamic major_interval logic for tick LABELS
if plot_type == 'current' and y_max <= 2.5:
major_interval = 0.5
elif y_max <= 10:
major_interval = 2
elif y_max <= 25:
major_interval = 5
else:
major_interval = y_max / 5.0
ax.yaxis.set_major_locator(MultipleLocator(major_interval))
ax.yaxis.set_minor_locator(MultipleLocator(1))
# Disable the default major grid, but keep the minor one
ax.yaxis.grid(False, which='major')
ax.yaxis.grid(True, which='minor', linestyle='--', linewidth=0.6, zorder=0)
# Draw custom lines for 5 and 10 multiples, which are now the only major grid lines
for y_val in range(int(y_min), int(y_max) + 1):
if y_val == 0: continue
if y_val % 10 == 0:
ax.axhline(y=y_val, color='maroon', linestyle='--', linewidth=1.2, zorder=1)
elif y_val % 5 == 0:
ax.axhline(y=y_val, color='midnightblue', linestyle='--', linewidth=1.2, zorder=1)
# Keep the x-axis grid
ax.xaxis.grid(True, which='major', linestyle='--', linewidth=0.8)
# --- Formatting the x-axis (Time) ---
# Improve date formatting on the x-axis
# Apply formatting to the last subplot's x-axis
local_tz = gettz()
last_ax = axes[-1]
last_ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S'))
last_ax.xaxis.set_major_locator(plt.MaxNLocator(15)) # Limit the number of ticks
plt.xlabel('Time')
if not df.empty:
last_ax.set_xlim(df['timestamp'].iloc[0], df['timestamp'].iloc[-1])
last_ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S', tz=local_tz))
last_ax.xaxis.set_major_locator(plt.MaxNLocator(15))
plt.xlabel(f'Time ({local_tz.tzname(df["timestamp"].iloc[-1])})')
plt.xticks(rotation=45)
# Add a main title to the figure
# --- Add a main title and subtitle ---
start_time = df['timestamp'].iloc[0].strftime('%Y-%m-%d %H:%M:%S')
end_time = df['timestamp'].iloc[-1].strftime('%H:%M:%S')
fig.suptitle(f'ODROID Power Log ({start_time} to {end_time})', fontsize=16, y=0.95)
main_title = f'PowerMate Log ({start_time} to {end_time})'
# Adjust layout to prevent titles/labels from overlapping
plt.tight_layout(rect=[0, 0, 1, 0.94])
subtitle_parts = []
if avg_interval_ms > 0:
subtitle_parts.append(f'Avg. Interval: {avg_interval_ms:.2f} ms')
voltage_strings = [f'{source.upper()} Avg: {avg_v:.2f} V' for source, avg_v in avg_voltages.items()]
if voltage_strings:
subtitle_parts.extend(voltage_strings)
subtitle = ' | '.join(subtitle_parts)
full_title = main_title
if subtitle:
full_title += f'\n{subtitle}'
fig.suptitle(full_title, fontsize=14)
# Adjust layout to make space for the subtitle
plt.tight_layout(rect=[0, 0, 1, 0.93])
# --- Save the plot to a file ---
try:
@@ -99,9 +185,17 @@ def main():
help="Types of plots to generate. Choose from 'power', 'voltage', 'current'. "
"Default is to generate all three."
)
parser.add_argument(
"-s", "--source",
nargs='+',
choices=['vin', 'main', 'usb'],
default=['vin', 'main', 'usb'],
help="Power sources to plot. Choose from 'vin', 'main', 'usb'. "
"Default is to plot all three."
)
args = parser.parse_args()
plot_power_data(args.input_csv, args.output_image, args.type)
plot_power_data(args.input_csv, args.output_image, args.type, args.source)
if __name__ == "__main__":

View File

@@ -3,7 +3,7 @@ import asyncio
import csv
import requests
import websockets
from datetime import datetime
from datetime import datetime, timezone
# Import the status_pb2.py file generated by `protoc`.
# This file must be in the same directory as logger.py.
@@ -70,7 +70,7 @@ class OdroidPowerLogger:
# Write header
header = [
'timestamp', 'uptime_sec',
'timestamp', 'uptime_ms',
'vin_voltage', 'vin_current', 'vin_power',
'main_voltage', 'main_current', 'main_power',
'usb_voltage', 'usb_current', 'usb_power'
@@ -97,10 +97,10 @@ class OdroidPowerLogger:
# Process only if the payload type is 'sensor_data'
if status_message.WhichOneof('payload') == 'sensor_data':
sensor_data = status_message.sensor_data
ts_dt = datetime.fromtimestamp(sensor_data.timestamp)
ts_str = ts_dt.strftime('%Y-%m-%d %H:%M:%S')
ts_dt = datetime.fromtimestamp(sensor_data.timestamp_ms / 1000, tz=timezone.utc)
ts_str_print = ts_dt.strftime('%Y-%m-%d %H:%M:%S UTC')
print(f"--- {ts_str} (Uptime: {sensor_data.uptime_sec}s) ---")
print(f"--- {ts_str_print} (Uptime: {sensor_data.uptime_ms / 1000}s) ---")
# Print data for each channel
for name, channel in [('VIN', sensor_data.vin), ('MAIN', sensor_data.main),
@@ -110,8 +110,9 @@ class OdroidPowerLogger:
# Write to CSV if enabled
if csv_writer:
ts_iso_csv = ts_dt.isoformat(timespec='milliseconds').replace('+00:00', 'Z')
row = [
ts_dt.isoformat(), sensor_data.uptime_sec,
ts_iso_csv, sensor_data.uptime_ms,
sensor_data.vin.voltage, sensor_data.vin.current, sensor_data.vin.power,
sensor_data.main.voltage, sensor_data.main.current, sensor_data.main.power,
sensor_data.usb.voltage, sensor_data.usb.current, sensor_data.usb.power

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 236 KiB

View File

@@ -44,6 +44,7 @@ enum nconfig_type
USB_CURRENT_LIMIT, ///< The maximum current limit for the USB out.
PAGE_USERNAME, ///< Webpage username
PAGE_PASSWORD, ///< Webpage password
SENSOR_PERIOD_MS, ///< Sensor period
NCONFIG_TYPE_MAX, ///< Sentinel for the maximum number of configuration types.
};

View File

@@ -30,6 +30,7 @@ const static char* keys[NCONFIG_TYPE_MAX] = {
[USB_CURRENT_LIMIT] = "usb_climit",
[PAGE_USERNAME] = "username",
[PAGE_PASSWORD] = "password",
[SENSOR_PERIOD_MS] = "sensor_period",
};
struct default_value
@@ -54,6 +55,7 @@ struct default_value const default_values[] = {
{USB_CURRENT_LIMIT, "3.0"},
{PAGE_USERNAME, "admin"},
{PAGE_PASSWORD, "password"},
{SENSOR_PERIOD_MS, "1000"},
};
esp_err_t init_nconfig()

View File

@@ -25,8 +25,8 @@ typedef struct _SensorData {
SensorChannelData main;
bool has_vin;
SensorChannelData vin;
uint32_t timestamp;
uint32_t uptime_sec;
uint64_t timestamp_ms;
uint64_t uptime_ms;
} SensorData;
/* Contains WiFi connection status */
@@ -85,8 +85,8 @@ extern "C" {
#define SensorData_usb_tag 1
#define SensorData_main_tag 2
#define SensorData_vin_tag 3
#define SensorData_timestamp_tag 4
#define SensorData_uptime_sec_tag 5
#define SensorData_timestamp_ms_tag 4
#define SensorData_uptime_ms_tag 5
#define WifiStatus_connected_tag 1
#define WifiStatus_ssid_tag 2
#define WifiStatus_rssi_tag 3
@@ -111,8 +111,8 @@ X(a, STATIC, SINGULAR, FLOAT, power, 3)
X(a, STATIC, OPTIONAL, MESSAGE, usb, 1) \
X(a, STATIC, OPTIONAL, MESSAGE, main, 2) \
X(a, STATIC, OPTIONAL, MESSAGE, vin, 3) \
X(a, STATIC, SINGULAR, UINT32, timestamp, 4) \
X(a, STATIC, SINGULAR, UINT32, uptime_sec, 5)
X(a, STATIC, SINGULAR, UINT64, timestamp_ms, 4) \
X(a, STATIC, SINGULAR, UINT64, uptime_ms, 5)
#define SensorData_CALLBACK NULL
#define SensorData_DEFAULT NULL
#define SensorData_usb_MSGTYPE SensorChannelData
@@ -172,7 +172,7 @@ extern const pb_msgdesc_t StatusMessage_msg;
#define LoadSwStatus_size 4
#define STATUS_PB_H_MAX_SIZE SensorData_size
#define SensorChannelData_size 15
#define SensorData_size 63
#define SensorData_size 73
#ifdef __cplusplus
} /* extern "C" */

View File

@@ -4,6 +4,7 @@
#include "monitor.h"
#include <nconfig.h>
#include <sys/time.h>
#include <time.h>
#include "climit.h"
#include "esp_log.h"
@@ -89,9 +90,10 @@ static void send_pb_message(const pb_msgdesc_t* fields, const void* src_struct)
static void sensor_timer_callback(void* arg)
{
int64_t uptime_us = esp_timer_get_time();
uint32_t uptime_sec = (uint32_t)(uptime_us / 1000000);
uint32_t timestamp = (uint32_t)time(NULL);
struct timeval tv;
gettimeofday(&tv, NULL);
uint64_t timestamp_ms = (uint64_t)tv.tv_sec * 1000 + (uint64_t)tv.tv_usec / 1000;
uint64_t uptime_ms = (uint64_t)esp_timer_get_time() / 1000;
StatusMessage message = StatusMessage_init_zero;
message.which_payload = StatusMessage_sensor_data_tag;
@@ -120,8 +122,8 @@ static void sensor_timer_callback(void* arg)
// datalog_add(timestamp, channel_data_log);
sensor_data->timestamp = timestamp;
sensor_data->uptime_sec = uptime_sec;
sensor_data->timestamp_ms = timestamp_ms;
sensor_data->uptime_ms = uptime_ms;
send_pb_message(StatusMessage_fields, &message);
}
@@ -289,6 +291,25 @@ void init_status_monitor()
xTaskCreate(shutdown_load_sw_task, "shutdown_sw_task", configMINIMAL_STACK_SIZE * 3, NULL, 15,
&shutdown_task_handle);
ESP_ERROR_CHECK(esp_timer_start_periodic(sensor_timer, 1000000));
nconfig_read(SENSOR_PERIOD_MS, buf, sizeof(buf));
ESP_ERROR_CHECK(esp_timer_start_periodic(sensor_timer, strtol(buf, NULL, 10) * 1000));
ESP_ERROR_CHECK(esp_timer_start_periodic(wifi_status_timer, 1000000 * 5));
}
esp_err_t update_sensor_period(int period)
{
if (period < 500 || period > 10000) // 0.5 sec ~ 10 sec
{
return ESP_ERR_INVALID_ARG;
}
char buf[10];
sprintf(buf, "%d", period);
esp_err_t err = nconfig_write(SENSOR_PERIOD_MS, buf);
if (err != ESP_OK) {
return err;
}
esp_timer_stop(sensor_timer);
return esp_timer_start_periodic(sensor_timer, period * 1000);
}

View File

@@ -20,5 +20,6 @@ typedef struct
} sensor_data_t;
void init_status_monitor();
esp_err_t update_sensor_period(int period);
#endif // ODROID_REMOTE_HTTP_MONITOR_H

View File

@@ -6,6 +6,7 @@
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_timer.h"
#include "monitor.h"
#include "nconfig.h"
#include "webserver.h"
#include "wifi.h"
@@ -47,6 +48,11 @@ static esp_err_t setting_get_handler(httpd_req_t* req)
cJSON_AddStringToObject(root, "baudrate", buf);
}
if (nconfig_read(SENSOR_PERIOD_MS, buf, sizeof(buf)) == ESP_OK)
{
cJSON_AddStringToObject(root, "period", buf);
}
// Add current limits to the response
if (nconfig_read(VIN_CURRENT_LIMIT, buf, sizeof(buf)) == ESP_OK)
{
@@ -174,6 +180,7 @@ static esp_err_t setting_post_handler(httpd_req_t* req)
cJSON* net_type_item = cJSON_GetObjectItem(root, "net_type");
cJSON* ssid_item = cJSON_GetObjectItem(root, "ssid");
cJSON* baud_item = cJSON_GetObjectItem(root, "baudrate");
cJSON* period_item = cJSON_GetObjectItem(root, "period");
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");
@@ -289,6 +296,13 @@ static esp_err_t setting_post_handler(httpd_req_t* req)
change_baud_rate(strtol(baudrate, NULL, 10));
httpd_resp_sendstr(req, "{\"status\":\"baudrate_updated\"}");
}
else if (period_item && cJSON_IsString(period_item))
{
const char* period_str = period_item->valuestring;
ESP_LOGI(TAG, "Received period set request: %s", period_str);
update_sensor_period(strtol(period_str, NULL, 10));
httpd_resp_sendstr(req, "{\"status\":\"period_updated\"}");
}
else if (vin_climit_item || main_climit_item || usb_climit_item)
{
char num_buf[10];

View File

@@ -141,8 +141,9 @@
<div class="card border-top-0 rounded-0 rounded-bottom">
<div class="card-body">
<div class="d-flex justify-content-end mb-3">
<a class="btn btn-primary" download="datalog.csv" href="/datalog.csv" style="display: none"><i
class="bi bi-download me-1"></i> Download CSV</a>
<button id="record-button" class="btn btn-success me-2"><i class="bi bi-record-circle me-1"></i>Record</button>
<button id="stop-button" class="btn btn-danger me-2" style="display: none;"><i class="bi bi-stop-circle me-1"></i>Stop</button>
<button id="download-csv-button" class="btn btn-primary" style="display: none;"><i class="bi bi-download me-1"></i>Download CSV</button>
</div>
<h5 class="card-title text-center mb-3">Power Metrics</h5>
<div class="row">
@@ -359,7 +360,7 @@
</form>
</div>
<div class="tab-pane fade" id="device-settings-pane" role="tabpanel">
<div class="mb-3">
<div class="mb-3 p-3 border rounded">
<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>
@@ -372,6 +373,16 @@
<option value="921600">921600</option>
<option value="1500000" selected>1500000</option>
</select>
<div class="d-flex justify-content-end mt-2">
<button type="button" class="btn btn-primary btn-sm" id="baud-rate-apply-button">Apply</button>
</div>
</div>
<div class="mb-3 p-3 border rounded">
<label for="period-slider" class="form-label">Sensor Period: <span class="fw-bold text-primary" id="period-value">...</span> ms</label>
<input type="range" class="form-range" id="period-slider" min="500" max="5000" step="100">
<div class="d-flex justify-content-end mt-2">
<button type="button" class="btn btn-primary btn-sm" id="period-apply-button">Apply</button>
</div>
</div>
<hr>
<div class="mb-3">
@@ -380,7 +391,6 @@
<button type="button" class="btn btn-danger" id="reboot-button">Reboot Now</button>
</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>

View File

@@ -104,7 +104,6 @@ export async function postNetworkSettings(payload) {
* 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', {
@@ -113,7 +112,24 @@ export async function postBaudRateSetting(baudrate) {
'Content-Type': 'application/json',
...getAuthHeaders(),
},
body: JSON.stringify({baudrate}),
body: JSON.stringify({ baudrate }),
});
return await handleResponse(response);
}
/**
* Posts the selected sensor period to the server.
* @param {string} period The selected period in milliseconds.
* @returns {Promise<Response>} A promise that resolves to the raw fetch response.
*/
export async function postPeriodSetting(period) {
const response = await fetch('/api/setting', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders(),
},
body: JSON.stringify({ period }),
});
return await handleResponse(response);
}

View File

@@ -216,7 +216,7 @@ function updateSingleChart(chart, metric, data, timeLabel) {
* @param {Object} data - The new sensor data object from the WebSocket.
*/
export function updateCharts(data) {
const timeLabel = new Date(data.timestamp * 1000).toLocaleTimeString();
const timeLabel = new Date(data.timestamp).toLocaleTimeString();
updateSingleChart(charts.power, 'power', data, timeLabel);
updateSingleChart(charts.voltage, 'voltage', data, timeLabel);

View File

@@ -76,6 +76,9 @@ 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');
export const periodSlider = document.getElementById('period-slider');
export const periodValue = document.getElementById('period-value');
export const periodApplyButton = document.getElementById('period-apply-button');
export const rebootButton = document.getElementById('reboot-button');
// --- Current Limit Settings Elements ---

View File

@@ -77,8 +77,9 @@ export function setupEventListeners() {
dom.networkApplyButton.addEventListener('click', ui.applyNetworkSettings);
dom.apModeApplyButton.addEventListener('click', ui.applyApModeSettings);
dom.baudRateApplyButton.addEventListener('click', ui.applyBaudRateSettings);
dom.periodApplyButton.addEventListener('click', ui.applyPeriodSettings);
// --- Device Settings (Reboot) ---
// --- Device Settings (Reboot & Period Slider) ---
if (dom.rebootButton) {
dom.rebootButton.addEventListener('click', () => {
if (confirm('Are you sure you want to reboot the device?')) {
@@ -101,6 +102,12 @@ export function setupEventListeners() {
});
}
if (dom.periodSlider) {
dom.periodSlider.addEventListener('input', () => {
dom.periodValue.textContent = dom.periodSlider.value;
});
}
// --- Current Limit Settings ---
dom.vinSlider.addEventListener('input', () => updateSliderValue(dom.vinSlider, dom.vinValueSpan));
dom.mainSlider.addEventListener('input', () => updateSliderValue(dom.mainSlider, dom.mainValueSpan));

View File

@@ -30,6 +30,8 @@ import {setupEventListeners} from './events.js';
// --- Globals ---
// StatusMessage is imported directly from the generated proto.js file.
let isRecording = false;
let recordedData = [];
// --- DOM Elements ---
const loginContainer = document.getElementById('login-container');
@@ -50,6 +52,11 @@ const newUsernameInput = document.getElementById('new-username');
const newPasswordInput = document.getElementById('new-password');
const confirmPasswordInput = document.getElementById('confirm-password');
// Metrics Tab DOM Elements
const recordButton = document.getElementById('record-button');
const stopButton = document.getElementById('stop-button');
const downloadCsvButton = document.getElementById('download-csv-button');
// --- WebSocket Event Handlers ---
@@ -88,13 +95,18 @@ function onWsMessage(event) {
USB: sensorData.usb,
MAIN: sensorData.main,
VIN: sensorData.vin,
timestamp: sensorData.timestamp
timestamp: sensorData.timestampMs,
uptime: sensorData.uptimeMs
};
updateSensorUI(sensorPayload);
if (isRecording) {
recordedData.push(sensorPayload);
}
// Update uptime separately from the sensor data payload
if (sensorData.uptimeSec !== undefined) {
updateUptimeUI(sensorData.uptimeSec);
if (sensorData.uptimeMs !== undefined) {
updateUptimeUI(sensorData.uptimeMs / 1000);
}
}
break;
@@ -233,6 +245,70 @@ function setupThemeToggles() {
});
}
// --- Recording and Downloading Functions ---
function startRecording() {
isRecording = true;
recordedData = [];
recordButton.style.display = 'none';
stopButton.style.display = 'inline-block';
downloadCsvButton.style.display = 'none';
console.log('Recording started.');
}
function stopRecording() {
isRecording = false;
recordButton.style.display = 'inline-block';
stopButton.style.display = 'none';
if (recordedData.length > 0) {
downloadCsvButton.style.display = 'inline-block';
}
console.log('Recording stopped. Data points captured:', recordedData.length);
}
function downloadCSV() {
if (recordedData.length === 0) {
alert('No data to download.');
return;
}
const headers = [
'timestamp', 'uptime_ms',
'vin_voltage', 'vin_current', 'vin_power',
'main_voltage', 'main_current', 'main_power',
'usb_voltage', 'usb_current', 'usb_power'
];
const csvRows = [headers.join(',')];
recordedData.forEach(data => {
const timestamp = new Date(data.timestamp).toISOString();
const row = [
timestamp,
data.uptime,
Number(data.VIN.voltage).toFixed(3), Number(data.VIN.current).toFixed(3), Number(data.VIN.power).toFixed(3),
Number(data.MAIN.voltage).toFixed(3), Number(data.MAIN.current).toFixed(3), Number(data.MAIN.power).toFixed(3),
Number(data.USB.voltage).toFixed(3), Number(data.USB.current).toFixed(3), Number(data.USB.power).toFixed(3)
];
csvRows.push(row.join(','));
});
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
const now = new Date();
const pad = (num) => num.toString().padStart(2, '0');
const datePart = `${now.getFullYear().toString().slice(-2)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const timePart = `${pad(now.getHours())}-${pad(now.getMinutes())}`;
const filename = `powermate_${datePart}_${timePart}.csv`;
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// --- Application Initialization ---
@@ -263,6 +339,12 @@ function initializeMainAppContent() {
initializeVersion();
setupEventListeners(); // Attach main app event listeners
logoutButton.addEventListener('click', handleLogout); // Attach logout listener
// Attach listeners for recording/downloading
recordButton.addEventListener('click', startRecording);
stopButton.addEventListener('click', stopRecording);
downloadCsvButton.addEventListener('click', downloadCSV);
connect();
// Attach user settings form listener

View File

@@ -301,6 +301,24 @@ export async function applyBaudRateSettings() {
}
}
/**
* Applies the selected sensor period by sending it to the server.
*/
export async function applyPeriodSettings() {
const period = dom.periodSlider.value;
dom.periodApplyButton.disabled = true;
dom.periodApplyButton.innerHTML = `<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Applying...`;
try {
await api.postPeriodSetting(period);
} catch (error) {
console.error('Error applying period:', error);
} finally {
dom.periodApplyButton.disabled = false;
dom.periodApplyButton.innerHTML = 'Apply';
}
}
/**
* Fetches and displays the current network and device settings in the settings modal.
*/
@@ -338,6 +356,10 @@ export async function initializeSettings() {
if (data.baudrate) {
dom.baudRateSelect.value = data.baudrate;
}
if (data.period) {
dom.periodSlider.value = data.period;
dom.periodValue.textContent = data.period;
}
} catch (error) {
console.error('Error initializing settings:', error);

View File

@@ -25,6 +25,7 @@ export function debounce(func, delay) {
* @returns {string} The formatted uptime string.
*/
export function formatUptime(totalSeconds) {
totalSeconds = Math.floor(totalSeconds);
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);

View File

@@ -12,8 +12,8 @@ message SensorData {
SensorChannelData usb = 1;
SensorChannelData main = 2;
SensorChannelData vin = 3;
uint32 timestamp = 4;
uint32 uptime_sec = 5;
uint64 timestamp_ms = 4;
uint64 uptime_ms = 5;
}
// Contains WiFi connection status