22 Commits

Author SHA1 Message Date
6a5ec86505 update logger README to include detailed usage instructions and examples
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-10 10:01:26 +09:00
e20f4b9f74 csv_2_plot: update title margin
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-10 10:01:26 +09:00
56e1f619e1 csv_2_plot: update y-axis gridline colors
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-10 10:01:26 +09:00
8930a36eaf csv_2_plot: improve plot configuration and add average voltage/interval calculations
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-10 10:01:26 +09:00
194474fdff logger: save time on UTC
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-10 10:00:27 +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
cefe34c7bc sta: fix reconnect ap when ap lost
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-05 12:19:20 +09:00
388e75864a update readme
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-11-20 09:31:59 +09:00
b33db504a3 example: add .gitignore
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-11-20 09:08:32 +09:00
0765c47e4a example: add plot option
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-11-20 09:07:34 +09:00
e7d97c1d6f delete unuse partition
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-11-20 08:57:14 +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
26 changed files with 534 additions and 104 deletions

View File

@@ -77,4 +77,13 @@ 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.
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)

5
example/logger/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/.venv/
/venv/
status_pb2.py
test.csv
plot.png

View File

@@ -1,41 +1,140 @@
# Power Consumption Logger Example
# Odroid PowerMate Logger and Plotter
Based on this script, you can monitor power consumption and implement graph plotting.
This directory contains two Python scripts to log power data from an Odroid PowerMate device and visualize it.
## How to Run the Script
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.
### Install Python Virtual Environment
## Prerequisites
```shell
sudo apt install virtualenv
virtualenv venv
source venv/bin/activate
### 1. Clone this example
```bash
git clone https://github.com/hardkernel/odroid-powermate.git
cd odroid-powermate/example/logger
```
### Install require package
### 2. Python and Virtual Environment
```shell
pip install grpcio-tools requests websockets protobuf pandas matplotlib
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
```
### Build `status_pb2.py`
### 4. Protobuf Generated File
```shell
python -m grpc_tools.protoc -I ../../proto --python_out=. status.proto
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>
```
### Execute script
**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.
#### 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
**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
```
#### Plot data
The script will continue to log data until you stop it with `Ctrl+C`.
```shell
python3 csv_2_plot.py test.csv plot.png
### 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]
```
![plot.png](plot.png)
**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.

View File

@@ -1,23 +1,33 @@
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):
def plot_power_data(csv_path, output_path, plot_types, sources):
"""
Reads power data from a CSV file and generates a plot image.
Args:
csv_path (str): The path to the input CSV file.
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
@@ -25,54 +35,134 @@ def plot_power_data(csv_path, output_path):
print(f"An error occurred while reading the CSV file: {e}")
return
# Create a figure and a set of subplots (3 rows, 1 column)
# sharex=True makes all subplots share the same x-axis (time)
fig, axes = plt.subplots(3, 1, figsize=(15, 18), sharex=True)
# --- 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
# --- Plot 1: Power (W) ---
ax1 = axes[0]
ax1.plot(df['timestamp'], df['vin_power'], label='VIN', color='red')
ax1.plot(df['timestamp'], df['main_power'], label='MAIN', color='green')
ax1.plot(df['timestamp'], df['usb_power'], label='USB', color='blue')
ax1.set_title('Power Consumption')
ax1.set_ylabel('Power (W)')
ax1.legend()
ax1.grid(True, which='both', linestyle='--', linewidth=0.5)
# --- 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()
# --- Plot 2: Voltage (V) ---
ax2 = axes[1]
ax2.plot(df['timestamp'], df['vin_voltage'], label='VIN', color='red')
ax2.plot(df['timestamp'], df['main_voltage'], label='MAIN', color='green')
ax2.plot(df['timestamp'], df['usb_voltage'], label='USB', color='blue')
ax2.set_title('Voltage')
ax2.set_ylabel('Voltage (V)')
ax2.legend()
ax2.grid(True, which='both', linestyle='--', linewidth=0.5)
# --- 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]}
}
# --- Plot 3: Current (A) ---
ax3 = axes[2]
ax3.plot(df['timestamp'], df['vin_current'], label='VIN', color='red')
ax3.plot(df['timestamp'], df['main_current'], label='MAIN', color='green')
ax3.plot(df['timestamp'], df['usb_current'], label='USB', color='blue')
ax3.set_title('Current')
ax3.set_ylabel('Current (A)')
ax3.legend()
ax3.grid(True, which='both', linestyle='--', linewidth=0.5)
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
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']):
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()
# --- 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
ax3.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S'))
ax3.xaxis.set_major_locator(plt.MaxNLocator(15)) # Limit the number of ticks
plt.xlabel('Time')
local_tz = gettz()
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])})')
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.98])
# --- Save the plot to a file ---
try:
@@ -86,9 +176,25 @@ def main():
parser = argparse.ArgumentParser(description="Generate a plot from an Odroid PowerMate 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(
"-t", "--type",
nargs='+',
choices=['power', 'voltage', 'current'],
default=['power', 'voltage', 'current'],
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)
plot_power_data(args.input_csv, args.output_image, args.type, args.source)
if __name__ == "__main__":

BIN
example/logger/img/plot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

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,11 +110,12 @@ 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,
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
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}"
]
csv_writer.writerow(row)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 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

@@ -8,5 +8,6 @@
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,6 +81,14 @@ 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)
{
@@ -100,6 +108,16 @@ 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,9 +16,13 @@
#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)
@@ -46,10 +50,18 @@ 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_RED, BLINK_TRIPLE);
led_set(LED_BLU, 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));
// ESP-IDF will automatically try to reconnect by default.
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();
}
}
}
else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP)
{

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

@@ -3,4 +3,3 @@
nvs,data,nvs,0x9000,24K,
phy_init,data,phy,0xf000,4K,
factory,app,factory,0x10000,2M,
littlefs, data, littlefs, ,1536K,
1 # ESP-IDF Partition Table
3 nvs,data,nvs,0x9000,24K,
4 phy_init,data,phy,0xf000,4K,
5 factory,app,factory,0x10000,2M,
littlefs, data, littlefs, ,1536K,

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