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 ### Install Python Virtual Environment
```shell
git clone https://github.com/hardkernel/odroid-powermate.git
cd odroid-powermate/example/logger
```
```shell ```shell
sudo apt install virtualenv sudo apt install virtualenv
virtualenv venv virtualenv venv
@@ -35,7 +40,7 @@ python3 logger.py -u admin -p password -o test.csv 192.168.30.5
#### Plot data #### Plot data
```shell ```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) ![plot.png](plot.png)

View File

@@ -3,9 +3,11 @@ import argparse
import matplotlib.dates as mdates import matplotlib.dates as mdates
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import pandas as pd 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. 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. output_path (str): The path to save the output plot image.
plot_types (list): A list of strings indicating which plots to generate plot_types (list): A list of strings indicating which plots to generate
(e.g., ['power', 'voltage', 'current']). (e.g., ['power', 'voltage', 'current']).
sources (list): A list of strings indicating which power sources to plot
(e.g., ['vin', 'main', 'usb']).
""" """
try: try:
# Read the CSV file into a pandas DataFrame # Read the CSV file into a pandas DataFrame
# The 'timestamp' column is parsed as dates
df = pd.read_csv(csv_path, parse_dates=['timestamp']) df = pd.read_csv(csv_path, parse_dates=['timestamp'])
print(f"Successfully loaded {len(df)} records from '{csv_path}'") 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: except FileNotFoundError:
print(f"Error: The file '{csv_path}' was not found.") print(f"Error: The file '{csv_path}' was not found.")
return 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}") print(f"An error occurred while reading the CSV file: {e}")
return 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 --- # --- Plotting Configuration ---
plot_configs = { scale_config = {
'power': {'title': 'Power Consumption', 'ylabel': 'Power (W)', 'power': {'steps': [5, 20, 50, 160]},
'cols': ['vin_power', 'main_power', 'usb_power']}, 'voltage': {'steps': [5, 10, 15, 25]},
'voltage': {'title': 'Voltage', 'ylabel': 'Voltage (V)', 'current': {'steps': [1, 2.5, 5, 10]}
'cols': ['vin_voltage', 'main_voltage', 'usb_voltage']},
'current': {'title': 'Current', 'ylabel': 'Current (A)', 'cols': ['vin_current', 'main_current', 'usb_current']}
} }
channel_labels = ['VIN', 'MAIN', 'USB'] plot_configs = {
channel_colors = ['red', 'green', 'blue'] '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) num_plots = len(plot_types)
if num_plots == 0: if num_plots == 0:
print("No plot types selected. Exiting.") print("No plot types selected. Exiting.")
return return
# Create a figure and a set of subplots based on the number of selected plot types. fig, axes = plt.subplots(num_plots, 1, figsize=(15, 9 * num_plots), sharex=True, squeeze=False)
# sharex=True makes all subplots share the same x-axis (time) axes = axes.flatten()
# 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
# --- Loop through selected plot types and generate plots --- # --- Loop through selected plot types and generate plots ---
for i, plot_type in enumerate(plot_types): for i, plot_type in enumerate(plot_types):
ax = axes[i] ax = axes[i]
config = plot_configs[plot_type] config = plot_configs[plot_type]
max_data_value = 0
for j, col_name in enumerate(config['cols']): 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_title(config['title'])
ax.set_ylabel(config['ylabel']) ax.set_ylabel(config['ylabel'])
ax.legend() 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) --- # --- Formatting the x-axis (Time) ---
# Improve date formatting on the x-axis local_tz = gettz()
# Apply formatting to the last subplot's x-axis
last_ax = axes[-1] 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 if not df.empty:
plt.xlabel('Time') 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) 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') start_time = df['timestamp'].iloc[0].strftime('%Y-%m-%d %H:%M:%S')
end_time = df['timestamp'].iloc[-1].strftime('%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 subtitle_parts = []
plt.tight_layout(rect=[0, 0, 1, 0.94]) 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 --- # --- Save the plot to a file ---
try: try:
@@ -99,9 +185,17 @@ def main():
help="Types of plots to generate. Choose from 'power', 'voltage', 'current'. " help="Types of plots to generate. Choose from 'power', 'voltage', 'current'. "
"Default is to generate all three." "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() 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__": if __name__ == "__main__":

View File

@@ -3,7 +3,7 @@ import asyncio
import csv import csv
import requests import requests
import websockets import websockets
from datetime import datetime from datetime import datetime, timezone
# Import the status_pb2.py file generated by `protoc`. # Import the status_pb2.py file generated by `protoc`.
# This file must be in the same directory as logger.py. # This file must be in the same directory as logger.py.
@@ -70,7 +70,7 @@ class OdroidPowerLogger:
# Write header # Write header
header = [ header = [
'timestamp', 'uptime_sec', 'timestamp', 'uptime_ms',
'vin_voltage', 'vin_current', 'vin_power', 'vin_voltage', 'vin_current', 'vin_power',
'main_voltage', 'main_current', 'main_power', 'main_voltage', 'main_current', 'main_power',
'usb_voltage', 'usb_current', 'usb_power' 'usb_voltage', 'usb_current', 'usb_power'
@@ -97,10 +97,10 @@ class OdroidPowerLogger:
# Process only if the payload type is 'sensor_data' # Process only if the payload type is 'sensor_data'
if status_message.WhichOneof('payload') == 'sensor_data': if status_message.WhichOneof('payload') == 'sensor_data':
sensor_data = status_message.sensor_data sensor_data = status_message.sensor_data
ts_dt = datetime.fromtimestamp(sensor_data.timestamp) ts_dt = datetime.fromtimestamp(sensor_data.timestamp_ms / 1000, tz=timezone.utc)
ts_str = ts_dt.strftime('%Y-%m-%d %H:%M:%S') 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 # Print data for each channel
for name, channel in [('VIN', sensor_data.vin), ('MAIN', sensor_data.main), for name, channel in [('VIN', sensor_data.vin), ('MAIN', sensor_data.main),
@@ -110,8 +110,9 @@ class OdroidPowerLogger:
# Write to CSV if enabled # Write to CSV if enabled
if csv_writer: if csv_writer:
ts_iso_csv = ts_dt.isoformat(timespec='milliseconds').replace('+00:00', 'Z')
row = [ 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.vin.voltage, sensor_data.vin.current, sensor_data.vin.power,
sensor_data.main.voltage, sensor_data.main.current, sensor_data.main.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 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. USB_CURRENT_LIMIT, ///< The maximum current limit for the USB out.
PAGE_USERNAME, ///< Webpage username PAGE_USERNAME, ///< Webpage username
PAGE_PASSWORD, ///< Webpage password PAGE_PASSWORD, ///< Webpage password
SENSOR_PERIOD_MS, ///< Sensor period
NCONFIG_TYPE_MAX, ///< Sentinel for the maximum number of configuration types. 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", [USB_CURRENT_LIMIT] = "usb_climit",
[PAGE_USERNAME] = "username", [PAGE_USERNAME] = "username",
[PAGE_PASSWORD] = "password", [PAGE_PASSWORD] = "password",
[SENSOR_PERIOD_MS] = "sensor_period",
}; };
struct default_value struct default_value
@@ -54,6 +55,7 @@ struct default_value const default_values[] = {
{USB_CURRENT_LIMIT, "3.0"}, {USB_CURRENT_LIMIT, "3.0"},
{PAGE_USERNAME, "admin"}, {PAGE_USERNAME, "admin"},
{PAGE_PASSWORD, "password"}, {PAGE_PASSWORD, "password"},
{SENSOR_PERIOD_MS, "1000"},
}; };
esp_err_t init_nconfig() esp_err_t init_nconfig()

View File

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

View File

@@ -4,6 +4,7 @@
#include "monitor.h" #include "monitor.h"
#include <nconfig.h> #include <nconfig.h>
#include <sys/time.h>
#include <time.h> #include <time.h>
#include "climit.h" #include "climit.h"
#include "esp_log.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) static void sensor_timer_callback(void* arg)
{ {
int64_t uptime_us = esp_timer_get_time(); struct timeval tv;
uint32_t uptime_sec = (uint32_t)(uptime_us / 1000000); gettimeofday(&tv, NULL);
uint32_t timestamp = (uint32_t)time(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; StatusMessage message = StatusMessage_init_zero;
message.which_payload = StatusMessage_sensor_data_tag; message.which_payload = StatusMessage_sensor_data_tag;
@@ -120,8 +122,8 @@ static void sensor_timer_callback(void* arg)
// datalog_add(timestamp, channel_data_log); // datalog_add(timestamp, channel_data_log);
sensor_data->timestamp = timestamp; sensor_data->timestamp_ms = timestamp_ms;
sensor_data->uptime_sec = uptime_sec; sensor_data->uptime_ms = uptime_ms;
send_pb_message(StatusMessage_fields, &message); 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, xTaskCreate(shutdown_load_sw_task, "shutdown_sw_task", configMINIMAL_STACK_SIZE * 3, NULL, 15,
&shutdown_task_handle); &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_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; } sensor_data_t;
void init_status_monitor(); void init_status_monitor();
esp_err_t update_sensor_period(int period);
#endif // ODROID_REMOTE_HTTP_MONITOR_H #endif // ODROID_REMOTE_HTTP_MONITOR_H

View File

@@ -6,6 +6,7 @@
#include "esp_log.h" #include "esp_log.h"
#include "esp_netif.h" #include "esp_netif.h"
#include "esp_timer.h" #include "esp_timer.h"
#include "monitor.h"
#include "nconfig.h" #include "nconfig.h"
#include "webserver.h" #include "webserver.h"
#include "wifi.h" #include "wifi.h"
@@ -47,6 +48,11 @@ static esp_err_t setting_get_handler(httpd_req_t* req)
cJSON_AddStringToObject(root, "baudrate", buf); 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 // Add current limits to the response
if (nconfig_read(VIN_CURRENT_LIMIT, buf, sizeof(buf)) == ESP_OK) 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* net_type_item = cJSON_GetObjectItem(root, "net_type");
cJSON* ssid_item = cJSON_GetObjectItem(root, "ssid"); cJSON* ssid_item = cJSON_GetObjectItem(root, "ssid");
cJSON* baud_item = cJSON_GetObjectItem(root, "baudrate"); 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* vin_climit_item = cJSON_GetObjectItem(root, "vin_current_limit");
cJSON* main_climit_item = cJSON_GetObjectItem(root, "main_current_limit"); cJSON* main_climit_item = cJSON_GetObjectItem(root, "main_current_limit");
cJSON* usb_climit_item = cJSON_GetObjectItem(root, "usb_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)); change_baud_rate(strtol(baudrate, NULL, 10));
httpd_resp_sendstr(req, "{\"status\":\"baudrate_updated\"}"); 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) else if (vin_climit_item || main_climit_item || usb_climit_item)
{ {
char num_buf[10]; char num_buf[10];

View File

@@ -141,8 +141,9 @@
<div class="card border-top-0 rounded-0 rounded-bottom"> <div class="card border-top-0 rounded-0 rounded-bottom">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-end mb-3"> <div class="d-flex justify-content-end mb-3">
<a class="btn btn-primary" download="datalog.csv" href="/datalog.csv" style="display: none"><i <button id="record-button" class="btn btn-success me-2"><i class="bi bi-record-circle me-1"></i>Record</button>
class="bi bi-download me-1"></i> Download CSV</a> <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> </div>
<h5 class="card-title text-center mb-3">Power Metrics</h5> <h5 class="card-title text-center mb-3">Power Metrics</h5>
<div class="row"> <div class="row">
@@ -359,7 +360,7 @@
</form> </form>
</div> </div>
<div class="tab-pane fade" id="device-settings-pane" role="tabpanel"> <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> <label for="baud-rate-select" class="form-label">UART Baud Rate</label>
<select class="form-select" id="baud-rate-select"> <select class="form-select" id="baud-rate-select">
<option value="9600">9600</option> <option value="9600">9600</option>
@@ -372,6 +373,16 @@
<option value="921600">921600</option> <option value="921600">921600</option>
<option value="1500000" selected>1500000</option> <option value="1500000" selected>1500000</option>
</select> </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> </div>
<hr> <hr>
<div class="mb-3"> <div class="mb-3">
@@ -380,7 +391,6 @@
<button type="button" class="btn btn-danger" id="reboot-button">Reboot Now</button> <button type="button" class="btn btn-danger" id="reboot-button">Reboot Now</button>
</div> </div>
<div class="d-flex justify-content-end pt-3 border-top mt-3"> <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> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div> </div>
</div> </div>

View File

@@ -104,7 +104,6 @@ export async function postNetworkSettings(payload) {
* Posts the selected UART baud rate to the server. * Posts the selected UART baud rate to the server.
* @param {string} baudrate The selected baud rate. * @param {string} baudrate The selected baud rate.
* @returns {Promise<Response>} A promise that resolves to the raw fetch response. * @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) { export async function postBaudRateSetting(baudrate) {
const response = await fetch('/api/setting', { const response = await fetch('/api/setting', {
@@ -113,7 +112,24 @@ export async function postBaudRateSetting(baudrate) {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...getAuthHeaders(), ...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); 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. * @param {Object} data - The new sensor data object from the WebSocket.
*/ */
export function updateCharts(data) { 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.power, 'power', data, timeLabel);
updateSingleChart(charts.voltage, 'voltage', data, timeLabel); updateSingleChart(charts.voltage, 'voltage', data, timeLabel);

View File

@@ -76,6 +76,9 @@ export const apPasswordInput = document.getElementById('ap-password');
// --- Device Settings Elements --- // --- Device Settings Elements ---
export const baudRateSelect = document.getElementById('baud-rate-select'); export const baudRateSelect = document.getElementById('baud-rate-select');
export const baudRateApplyButton = document.getElementById('baud-rate-apply-button'); 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'); export const rebootButton = document.getElementById('reboot-button');
// --- Current Limit Settings Elements --- // --- Current Limit Settings Elements ---

View File

@@ -77,8 +77,9 @@ export function setupEventListeners() {
dom.networkApplyButton.addEventListener('click', ui.applyNetworkSettings); dom.networkApplyButton.addEventListener('click', ui.applyNetworkSettings);
dom.apModeApplyButton.addEventListener('click', ui.applyApModeSettings); dom.apModeApplyButton.addEventListener('click', ui.applyApModeSettings);
dom.baudRateApplyButton.addEventListener('click', ui.applyBaudRateSettings); dom.baudRateApplyButton.addEventListener('click', ui.applyBaudRateSettings);
dom.periodApplyButton.addEventListener('click', ui.applyPeriodSettings);
// --- Device Settings (Reboot) --- // --- Device Settings (Reboot & Period Slider) ---
if (dom.rebootButton) { if (dom.rebootButton) {
dom.rebootButton.addEventListener('click', () => { dom.rebootButton.addEventListener('click', () => {
if (confirm('Are you sure you want to reboot the device?')) { 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 --- // --- Current Limit Settings ---
dom.vinSlider.addEventListener('input', () => updateSliderValue(dom.vinSlider, dom.vinValueSpan)); dom.vinSlider.addEventListener('input', () => updateSliderValue(dom.vinSlider, dom.vinValueSpan));
dom.mainSlider.addEventListener('input', () => updateSliderValue(dom.mainSlider, dom.mainValueSpan)); dom.mainSlider.addEventListener('input', () => updateSliderValue(dom.mainSlider, dom.mainValueSpan));

View File

@@ -30,6 +30,8 @@ import {setupEventListeners} from './events.js';
// --- Globals --- // --- Globals ---
// StatusMessage is imported directly from the generated proto.js file. // StatusMessage is imported directly from the generated proto.js file.
let isRecording = false;
let recordedData = [];
// --- DOM Elements --- // --- DOM Elements ---
const loginContainer = document.getElementById('login-container'); const loginContainer = document.getElementById('login-container');
@@ -50,6 +52,11 @@ const newUsernameInput = document.getElementById('new-username');
const newPasswordInput = document.getElementById('new-password'); const newPasswordInput = document.getElementById('new-password');
const confirmPasswordInput = document.getElementById('confirm-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 --- // --- WebSocket Event Handlers ---
@@ -88,13 +95,18 @@ function onWsMessage(event) {
USB: sensorData.usb, USB: sensorData.usb,
MAIN: sensorData.main, MAIN: sensorData.main,
VIN: sensorData.vin, VIN: sensorData.vin,
timestamp: sensorData.timestamp timestamp: sensorData.timestampMs,
uptime: sensorData.uptimeMs
}; };
updateSensorUI(sensorPayload); updateSensorUI(sensorPayload);
if (isRecording) {
recordedData.push(sensorPayload);
}
// Update uptime separately from the sensor data payload // Update uptime separately from the sensor data payload
if (sensorData.uptimeSec !== undefined) { if (sensorData.uptimeMs !== undefined) {
updateUptimeUI(sensorData.uptimeSec); updateUptimeUI(sensorData.uptimeMs / 1000);
} }
} }
break; 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 --- // --- Application Initialization ---
@@ -263,6 +339,12 @@ function initializeMainAppContent() {
initializeVersion(); initializeVersion();
setupEventListeners(); // Attach main app event listeners setupEventListeners(); // Attach main app event listeners
logoutButton.addEventListener('click', handleLogout); // Attach logout listener 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(); connect();
// Attach user settings form listener // 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. * Fetches and displays the current network and device settings in the settings modal.
*/ */
@@ -338,6 +356,10 @@ export async function initializeSettings() {
if (data.baudrate) { if (data.baudrate) {
dom.baudRateSelect.value = data.baudrate; dom.baudRateSelect.value = data.baudrate;
} }
if (data.period) {
dom.periodSlider.value = data.period;
dom.periodValue.textContent = data.period;
}
} catch (error) { } catch (error) {
console.error('Error initializing settings:', error); console.error('Error initializing settings:', error);

View File

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

View File

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