2 Commits

Author SHA1 Message Date
9d3b64744f example: add .gitignore
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-11-20 09:05:46 +09:00
42b99f6527 example: add plot option
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-11-20 09:02:06 +09:00
25 changed files with 91 additions and 504 deletions

View File

@@ -77,13 +77,4 @@ sudo apt install nodejs npm nanopb
1. After flashing, the ESP32 will either connect to the pre-configured Wi-Fi network or start an Access Point (APSTA).
2. Check the serial monitor logs to find the IP address assigned to the device in STA mode, or the default AP address (usually `192.168.4.1`).
3. Open a web browser and navigate to the device's IP address.
4. You should now see the ODROID Remote control panel.
## Docs
- Hardkernel WiKi: [https://wiki.odroid.com/accessory/powermate](https://wiki.odroid.com/accessory/powermate)
## Repo
- Hardkernel Github: [https://github.com/hardkernel/odroid-powermate](https://github.com/hardkernel/odroid-powermate)
- Original Repo: [https://github.com/shinys000114/odroid-powermate](https://github.com/shinys000114/odroid-powermate)
4. You should now see the ODROID Remote control panel.

View File

@@ -2,4 +2,4 @@
/venv/
status_pb2.py
test.csv
plot.png
plot.png

View File

@@ -1,140 +1,41 @@
# Odroid PowerMate Logger and Plotter
# Power Consumption Logger Example
This directory contains two Python scripts to log power data from an Odroid PowerMate device and visualize it.
Based on this script, you can monitor power consumption and implement graph plotting.
1. `logger.py`: Connects to the device's web server, authenticates, and logs real-time power data from its WebSocket to a CSV file.
2. `csv_2_plot.py`: Reads the generated CSV file and creates a plot image of the power, voltage, and current data over time.
## How to Run the Script
## Prerequisites
### Install Python Virtual Environment
### 1. Clone this example
```bash
git clone https://github.com/hardkernel/odroid-powermate.git
cd odroid-powermate/example/logger
```shell
sudo apt install virtualenv
virtualenv venv
source venv/bin/activate
```
### 2. Python and Virtual Environment
### Install require package
It is highly recommended to use a Python virtual environment to manage project dependencies and avoid conflicts with other projects.
Ensure you have Python 3 installed.
1. **Create a virtual environment:**
Open your terminal in this directory and run:
```bash
python3 -m venv venv
```
This will create a `venv` directory containing the Python interpreter and libraries.
2. **Activate the virtual environment:**
* **On Windows:**
```powershell
.\venv\Scripts\activate
```
* **On macOS and Linux:**
```bash
source venv/bin/activate
```
Your terminal prompt should now show `(venv)` at the beginning, indicating that the virtual environment is active.
### 3. Install Required Libraries
With the virtual environment activated, install the necessary Python packages:
```bash
pip3 install requests websockets protobuf pandas matplotlib python-dateutil
```shell
pip install grpcio-tools requests websockets protobuf pandas matplotlib
```
### 4. Protobuf Generated File
### Build `status_pb2.py`
The `logger.py` script uses Google Protocol Buffers (Protobuf) to decode real-time data from the WebSocket. This requires a Python file, `status_pb2.py`, which is generated from a Protobuf definition file (`status.proto`).
**How to Generate `status_pb2.py`:**
1. **Install Protobuf Compiler Tools:**
You need the `grpcio-tools` package, which includes the `protoc` compiler and Python plugins. You can install it via pip:
```bash
pip3 install grpcio-tools
```
2. **Locate the `.proto` file:**
Ensure you have the `status.proto` file in the current directory. This file defines the structure of the data messages.
3. **Run the Compiler:**
Execute the following command in your terminal. This command tells `protoc` to look for `status.proto` in the directory (`-I../../proto`) and generate the Python output file (`--python_out=.`) in the same place.
```bash
python3 -m grpc_tools.protoc -I../../proto --python_out=. status.proto
```
After running this command, the `status_pb2.py` file will be created, and `logger.py` will be able to use it.
## Usage
The process is a two-step workflow: first log the data, then plot it.
### Step 1: Log Power Data with `logger.py`
Run `logger.py` to connect to your Odroid Smart Power device and save the data to a CSV file.
**Syntax:**
```bash
python3 logger.py <host> -u <username> -p <password> -o <output_file.csv>
```shell
python -m grpc_tools.protoc -I ../../proto --python_out=. status.proto
```
**Arguments:**
* `host`: The IP address or hostname of the Odroid Smart Power device (e.g., `192.168.1.50`).
* `-u`, `--username`: The username for logging in.
* `-p`, `--password`: The password for logging in.
* `-o`, `--output`: The path to save the output CSV file. This is required if you want to generate a plot.
### Execute script
**Example:**
This command will log in and save the power data to `power_log.csv`.
```bash
python3 logger.py 192.168.1.50 -u admin -p mypassword -o power_log.csv
#### Power consumption collection
```shell
# python3 logger.py -u <username> -o <name.csv> -p <password> <address>
python3 logger.py -u admin -p password -o test.csv 192.168.30.5
```
The script will continue to log data until you stop it with `Ctrl+C`.
#### Plot data
### Step 2: Generate a Plot with `csv_2_plot.py`
Once you have a CSV log file, you can use `csv_2_plot.py` to create a visual graph.
You can also use the csv file recorded from PowerMate Web.
**Syntax:**
```bash
python3 csv_2_plot.py <input.csv> <output.png> [options]
```shell
python3 csv_2_plot.py test.csv plot.png [--type power voltage current]
```
**Arguments:**
* `input_csv`: The path to the CSV file generated by `logger.py`.
* `output_image`: The path to save the output plot image (e.g., `plot.png`).
**Optional Arguments:**
* `-t`, `--type`: Specify which plots to generate. Choices are `power`, `voltage`, `current`. Default is all three.
* `-s`, `--source`: Specify which power sources to include. Choices are `vin`, `main`, `usb`. Default is all three.
**Example 1: Default Plot**
This command reads `power_log.csv` and generates a plot containing power, voltage, and current for all sources, saving it as `power_graph.png`.
```bash
python3 csv_2_plot.py power_log.csv power_graph.png
```
**Example 2: Custom Plot**
This command generates a plot showing only the **power** and **current** for the **MAIN** and **USB** sources.
```bash
# main, usb power consumption
python csv_2_plot.py power_log.csv custom_plot.png --type power --source main usb
```
## Example Output
Running the plot script will generate an image file similar to this:
![plot.png](img/plot.png)
The 5-unit scale is highlighted with a blue dotted line, and the 10-unit scale is highlighted with a red dotted line.
![plot.png](plot.png)

View File

@@ -1,12 +1,11 @@
import argparse
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import os
import pandas as pd
from dateutil.tz import gettz
from matplotlib.ticker import MultipleLocator
def plot_power_data(csv_path, output_path, plot_types, sources):
def plot_power_data(csv_path, output_path, plot_types):
"""
Reads power data from a CSV file and generates a plot image.
@@ -15,19 +14,12 @@ def plot_power_data(csv_path, output_path, plot_types, sources):
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
@@ -35,134 +27,57 @@ def plot_power_data(csv_path, output_path, plot_types, sources):
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 ---
scale_config = {
'power': {'steps': [5, 20, 50, 160]},
'voltage': {'steps': [5, 10, 15, 25]},
'current': {'steps': [1, 2.5, 5, 10]}
}
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]}
'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']}
}
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]
channel_labels = ['VIN', 'MAIN', 'USB']
channel_colors = ['red', 'green', 'blue']
num_plots = len(plot_types)
if num_plots == 0:
print("No plot types selected. Exiting.")
return
fig, axes = plt.subplots(num_plots, 1, figsize=(15, 9 * num_plots), sharex=True, squeeze=False)
axes = axes.flatten()
# 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
# --- 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']):
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)
for j, col_name in enumerate(config['cols']):
ax.plot(df['timestamp'], df[col_name], label=channel_labels[j], color=channel_colors[j])
ax.set_title(config['title'])
ax.set_ylabel(config['ylabel'])
ax.legend()
# --- 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)
ax.grid(True, which='both', linestyle='--', linewidth=0.5)
# --- Formatting the x-axis (Time) ---
local_tz = gettz()
# Improve date formatting on the x-axis
# Apply formatting to the last subplot's x-axis
last_ax = axes[-1]
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])})')
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')
plt.xticks(rotation=45)
# --- Add a main title and subtitle ---
# Add a main title to the figure
start_time = df['timestamp'].iloc[0].strftime('%Y-%m-%d %H:%M:%S')
end_time = df['timestamp'].iloc[-1].strftime('%H:%M:%S')
main_title = f'PowerMate Log ({start_time} to {end_time})'
fig.suptitle(f'ODROID Power Log ({start_time} to {end_time})', fontsize=16, y=0.95)
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.98])
# Adjust layout to prevent titles/labels from overlapping
plt.tight_layout(rect=[0, 0, 1, 0.94])
# --- Save the plot to a file ---
try:
@@ -173,7 +88,7 @@ def plot_power_data(csv_path, output_path, plot_types, sources):
def main():
parser = argparse.ArgumentParser(description="Generate a plot from an Odroid PowerMate CSV log file.")
parser = argparse.ArgumentParser(description="Generate a plot from an Odroid Smart Power CSV log file.")
parser.add_argument("input_csv", help="Path to the input CSV log file.")
parser.add_argument("output_image", help="Path to save the output plot image (e.g., plot.png).")
parser.add_argument(
@@ -184,17 +99,9 @@ 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, args.source)
plot_power_data(args.input_csv, args.output_image, args.type)
if __name__ == "__main__":

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

View File

@@ -3,7 +3,7 @@ import asyncio
import csv
import requests
import websockets
from datetime import datetime, timezone
from datetime import datetime
# 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_ms',
'timestamp', 'uptime_sec',
'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_ms / 1000, tz=timezone.utc)
ts_str_print = ts_dt.strftime('%Y-%m-%d %H:%M:%S UTC')
ts_dt = datetime.fromtimestamp(sensor_data.timestamp)
ts_str = ts_dt.strftime('%Y-%m-%d %H:%M:%S')
print(f"--- {ts_str_print} (Uptime: {sensor_data.uptime_ms / 1000}s) ---")
print(f"--- {ts_str} (Uptime: {sensor_data.uptime_sec}s) ---")
# Print data for each channel
for name, channel in [('VIN', sensor_data.vin), ('MAIN', sensor_data.main),
@@ -110,12 +110,11 @@ class OdroidPowerLogger:
# Write to CSV if enabled
if csv_writer:
ts_iso_csv = ts_dt.isoformat(timespec='milliseconds').replace('+00:00', 'Z')
row = [
ts_iso_csv, sensor_data.uptime_ms,
f"{sensor_data.vin.voltage:.3f}", f"{sensor_data.vin.current:.3f}", f"{sensor_data.vin.power:.3f}",
f"{sensor_data.main.voltage:.3f}", f"{sensor_data.main.current:.3f}", f"{sensor_data.main.power:.3f}",
f"{sensor_data.usb.voltage:.3f}", f"{sensor_data.usb.current:.3f}", f"{sensor_data.usb.power:.3f}"
ts_dt.isoformat(), sensor_data.uptime_sec,
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
]
csv_writer.writerow(row)

BIN
example/logger/plot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

View File

@@ -44,7 +44,6 @@ 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,7 +30,6 @@ 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
@@ -55,7 +54,6 @@ 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;
uint64_t timestamp_ms;
uint64_t uptime_ms;
uint32_t timestamp;
uint32_t uptime_sec;
} 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_ms_tag 4
#define SensorData_uptime_ms_tag 5
#define SensorData_timestamp_tag 4
#define SensorData_uptime_sec_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, UINT64, timestamp_ms, 4) \
X(a, STATIC, SINGULAR, UINT64, uptime_ms, 5)
X(a, STATIC, SINGULAR, UINT32, timestamp, 4) \
X(a, STATIC, SINGULAR, UINT32, uptime_sec, 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 73
#define SensorData_size 63
#ifdef __cplusplus
} /* extern "C" */

View File

@@ -4,7 +4,6 @@
#include "monitor.h"
#include <nconfig.h>
#include <sys/time.h>
#include <time.h>
#include "climit.h"
#include "esp_log.h"
@@ -90,10 +89,9 @@ static void send_pb_message(const pb_msgdesc_t* fields, const void* src_struct)
static void sensor_timer_callback(void* arg)
{
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;
int64_t uptime_us = esp_timer_get_time();
uint32_t uptime_sec = (uint32_t)(uptime_us / 1000000);
uint32_t timestamp = (uint32_t)time(NULL);
StatusMessage message = StatusMessage_init_zero;
message.which_payload = StatusMessage_sensor_data_tag;
@@ -122,8 +120,8 @@ static void sensor_timer_callback(void* arg)
// datalog_add(timestamp, channel_data_log);
sensor_data->timestamp_ms = timestamp_ms;
sensor_data->uptime_ms = uptime_ms;
sensor_data->timestamp = timestamp;
sensor_data->uptime_sec = uptime_sec;
send_pb_message(StatusMessage_fields, &message);
}
@@ -291,25 +289,6 @@ void init_status_monitor()
xTaskCreate(shutdown_load_sw_task, "shutdown_sw_task", configMINIMAL_STACK_SIZE * 3, NULL, 15,
&shutdown_task_handle);
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(sensor_timer, 1000000));
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,6 +20,5 @@ 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,7 +6,6 @@
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_timer.h"
#include "monitor.h"
#include "nconfig.h"
#include "webserver.h"
#include "wifi.h"
@@ -48,11 +47,6 @@ 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)
{
@@ -180,7 +174,6 @@ 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");
@@ -296,13 +289,6 @@ 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

@@ -8,6 +8,5 @@
void wifi_init_sta(void);
void wifi_init_ap(void);
void initialize_sntp(void);
void wifi_set_auto_reconnect(bool enable);
#endif // ODROID_POWER_MATE_PRIV_WIFI_H

View File

@@ -81,14 +81,6 @@ void wifi_scan_aps(wifi_ap_record_t** ap_records, uint16_t* count)
*count = 0;
*ap_records = NULL;
wifi_set_auto_reconnect(false);
wifi_ap_record_t ap_info;
if (esp_wifi_sta_get_ap_info(&ap_info) != ESP_OK)
{
esp_wifi_disconnect();
}
// Start scan, this is a blocking call
if (esp_wifi_scan_start(NULL, true) == ESP_OK)
{
@@ -108,16 +100,6 @@ void wifi_scan_aps(wifi_ap_record_t** ap_records, uint16_t* count)
}
}
}
wifi_set_auto_reconnect(true);
if (esp_wifi_sta_get_ap_info(&ap_info) != ESP_OK)
{
if (!nconfig_value_is_not_set(WIFI_SSID))
{
wifi_connect();
}
}
}
esp_err_t wifi_get_current_ap_info(wifi_ap_record_t* ap_info)

View File

@@ -16,13 +16,9 @@
#include "wifi.h"
#include "indicator.h"
static bool s_auto_reconnect = true;
static const char* TAG = "WIFI";
void wifi_set_auto_reconnect(bool enable) { s_auto_reconnect = enable; }
static void wifi_event_handler(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data)
{
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_AP_STACONNECTED)
@@ -50,18 +46,10 @@ static void wifi_event_handler(void* arg, esp_event_base_t event_base, int32_t e
}
else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED)
{
led_set(LED_BLU, BLINK_TRIPLE);
led_set(LED_RED, BLINK_TRIPLE);
wifi_event_sta_disconnected_t* event = (wifi_event_sta_disconnected_t*)event_data;
ESP_LOGW(TAG, "Disconnected from AP, reason: %s", wifi_reason_str(event->reason));
if (event->reason != WIFI_REASON_ASSOC_LEAVE)
{
if (s_auto_reconnect && !nconfig_value_is_not_set(WIFI_SSID))
{
ESP_LOGI(TAG, "Connection lost, attempting to reconnect...");
esp_wifi_connect();
}
}
// ESP-IDF will automatically try to reconnect by default.
}
else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP)
{

View File

@@ -141,9 +141,8 @@
<div class="card border-top-0 rounded-0 rounded-bottom">
<div class="card-body">
<div class="d-flex justify-content-end mb-3">
<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>
<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>
</div>
<h5 class="card-title text-center mb-3">Power Metrics</h5>
<div class="row">
@@ -360,7 +359,7 @@
</form>
</div>
<div class="tab-pane fade" id="device-settings-pane" role="tabpanel">
<div class="mb-3 p-3 border rounded">
<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>
@@ -373,16 +372,6 @@
<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">
@@ -391,6 +380,7 @@
<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,6 +104,7 @@ 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', {
@@ -112,24 +113,7 @@ export async function postBaudRateSetting(baudrate) {
'Content-Type': 'application/json',
...getAuthHeaders(),
},
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 }),
body: JSON.stringify({baudrate}),
});
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).toLocaleTimeString();
const timeLabel = new Date(data.timestamp * 1000).toLocaleTimeString();
updateSingleChart(charts.power, 'power', data, timeLabel);
updateSingleChart(charts.voltage, 'voltage', data, timeLabel);

View File

@@ -76,9 +76,6 @@ 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,9 +77,8 @@ 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 & Period Slider) ---
// --- Device Settings (Reboot) ---
if (dom.rebootButton) {
dom.rebootButton.addEventListener('click', () => {
if (confirm('Are you sure you want to reboot the device?')) {
@@ -102,12 +101,6 @@ 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,8 +30,6 @@ 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');
@@ -52,11 +50,6 @@ 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 ---
@@ -95,18 +88,13 @@ function onWsMessage(event) {
USB: sensorData.usb,
MAIN: sensorData.main,
VIN: sensorData.vin,
timestamp: sensorData.timestampMs,
uptime: sensorData.uptimeMs
timestamp: sensorData.timestamp
};
updateSensorUI(sensorPayload);
if (isRecording) {
recordedData.push(sensorPayload);
}
// Update uptime separately from the sensor data payload
if (sensorData.uptimeMs !== undefined) {
updateUptimeUI(sensorData.uptimeMs / 1000);
if (sensorData.uptimeSec !== undefined) {
updateUptimeUI(sensorData.uptimeSec);
}
}
break;
@@ -245,70 +233,6 @@ 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 ---
@@ -339,12 +263,6 @@ 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,24 +301,6 @@ 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.
*/
@@ -356,10 +338,6 @@ 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,7 +25,6 @@ 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;
uint64 timestamp_ms = 4;
uint64 uptime_ms = 5;
uint32 timestamp = 4;
uint32 uptime_sec = 5;
}
// Contains WiFi connection status