33 Commits

Author SHA1 Message Date
a7ed7f9df2 WIP
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
(cherry picked from commit 61db696a03)
2025-12-16 06:08:22 +09:00
0c1fcc321c setting: support multi config items
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
(cherry picked from commit 8bad92f96a)
2025-12-16 06:08:17 +09:00
dde9611058 update installation instructions to include scipy dependency
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 18:21:02 +09:00
ce569b9410 csv_2_plot: refine data plotting logic to improve clarity and handling of unfiltered data
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 18:16:27 +09:00
a15996e493 csv_2_plot: update Y-axis scale configuration to include finer step intervals for power, voltage, and current
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 18:16:27 +09:00
cc8f1b77ed csv_2_plot: add support for data filtering with multiple filter types and window size customization
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 18:16:27 +09:00
09b1ca3089 logger: add additional websockets.asyncio imports to support async client connections
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 17:11:08 +09:00
9cb4734093 csv_2_plot: fix assignment of 'elapsed_seconds' column to avoid SettingWithCopyWarning
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 16:12:51 +09:00
b734915040 monitor: reduce averaging and adjust conversion times for improved responsiveness
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 15:28:32 +09:00
a84b504733 monitor: adjust sensor period range to support lower limit of 100ms
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 14:17:41 +09:00
222de64932 csv_2_plot: refine Y-axis major tick interval calculation for better alignment with data ranges
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 14:16:24 +09:00
1f35c52261 csv_2_plot: fix timestamp column assignment during timezone conversion
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 13:45:30 +09:00
14440094ac csv_2_plot: set matplotlib backend to 'Agg' for non-GUI environments
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 13:43:09 +09:00
11f9c72543 csv_2_plot: add support for customizing x-axis grid and label intervals
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 12:17:04 +09:00
c98e735410 csv_2_plot: add support for plotting with relative time on the x-axis
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 12:10:25 +09:00
5a505a5205 csv_2_plot: add Y-axis max value customization for voltage, current, and power
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 11:59:55 +09:00
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
Hardkernel Co., Ltd.
d04ac35126 Merge pull request #1 from shinys000114/master
Update schematic, Add example script
2025-11-19 16:39:01 +09:00
32 changed files with 2408 additions and 131 deletions

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 scipy
```
### 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 [--type power voltage current]
### 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,25 +1,59 @@
import argparse
import matplotlib
matplotlib.use('Agg')
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import pandas as pd
from dateutil.tz import gettz
from matplotlib.ticker import MultipleLocator, FuncFormatter
import math
from scipy.signal import savgol_filter
from scipy.ndimage import gaussian_filter1d
def plot_power_data(csv_path, output_path, plot_types):
def plot_power_data(csv_path, output_path, plot_types, sources,
voltage_y_max=None, current_y_max=None, power_y_max=None,
relative_time=False, time_x_line=None, time_x_label=None,
filter_type=None, window_size=None):
"""
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']).
plot_types (list): A list of strings indicating which plots to generate.
sources (list): A list of strings indicating which power sources to plot.
voltage_y_max (float, optional): Maximum value for the voltage plot's Y-axis.
current_y_max (float, optional): Maximum value for the current plot's Y-axis.
power_y_max (float, optional): Maximum value for the power plot's Y-axis.
relative_time (bool): If True, the x-axis will show elapsed time from the start.
time_x_line (float, optional): Interval in seconds for x-axis grid lines.
time_x_label (float, optional): Interval in seconds for x-axis labels.
filter_type (str, optional): The type of filter to apply.
window_size (int, optional): The window size for the filter in seconds.
"""
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}'")
if df.empty:
print("CSV file is empty. Exiting.")
return
# --- Time Handling ---
x_axis_data = df['timestamp']
if relative_time:
start_time = df['timestamp'].iloc[0]
df.loc[:, 'elapsed_seconds'] = (df['timestamp'] - start_time).dt.total_seconds()
x_axis_data = df['elapsed_seconds']
print("X-axis set to relative time (elapsed seconds).")
else:
# --- Timezone Conversion for absolute time ---
local_tz = gettz()
df.loc[:, 'timestamp'] = df['timestamp'].dt.tz_convert(local_tz)
print(f"Timestamp converted to local timezone: {local_tz}")
except FileNotFoundError:
print(f"Error: The file '{csv_path}' was not found.")
return
@@ -27,57 +61,211 @@ def plot_power_data(csv_path, output_path, plot_types):
print(f"An error occurred while reading the CSV file: {e}")
return
# --- Calculate Average Interval ---
avg_interval_s = 0
if len(df) > 1:
avg_interval = df['timestamp'].diff().mean()
avg_interval_s = avg_interval.total_seconds()
avg_interval_ms = avg_interval_s * 1000
# --- Auto-set window size if not provided ---
if filter_type and window_size is None and avg_interval_s > 0:
window_size = avg_interval_s * 15 # Default to 15x the average interval
print(f"Window size not specified. Automatically set to {window_size:.2f} seconds.")
# --- Calculate Average Voltages ---
avg_voltages = {}
for source in sources:
voltage_col = f'{source}_voltage'
if voltage_col in df.columns:
avg_voltages[source] = df[voltage_col].mean()
# --- Plotting Configuration ---
plot_configs = {
'power': {'title': 'Power Consumption', 'ylabel': 'Power (W)',
'cols': ['vin_power', 'main_power', 'usb_power']},
'voltage': {'title': 'Voltage', 'ylabel': 'Voltage (V)',
'cols': ['vin_voltage', 'main_voltage', 'usb_voltage']},
'current': {'title': 'Current', 'ylabel': 'Current (A)', 'cols': ['vin_current', 'main_current', 'usb_current']}
scale_config = {
'power': {'steps': [5, 7, 10, 20, 50, 160]},
'voltage': {'steps': [5, 7, 10, 15, 20, 25]},
'current': {'steps': [1, 2.5, 5, 7.5, 10]}
}
channel_labels = ['VIN', 'MAIN', 'USB']
channel_colors = ['red', 'green', 'blue']
plot_configs = {
'power': {'title': 'Power Consumption', 'ylabel': 'Power (W)', 'cols': [f'{s}_power' for s in sources]},
'voltage': {'title': 'Voltage', 'ylabel': 'Voltage (V)', 'cols': [f'{s}_voltage' for s in sources]},
'current': {'title': 'Current', 'ylabel': 'Current (A)', 'cols': [f'{s}_current' for s in sources]}
}
y_max_options = {
'power': power_y_max,
'voltage': voltage_y_max,
'current': current_y_max
}
channel_labels = [s.upper() for s in sources]
color_map = {'vin': 'red', 'main': 'green', 'usb': 'blue'}
channel_colors = [color_map[s] for s in sources]
num_plots = len(plot_types)
if num_plots == 0:
print("No plot types selected. Exiting.")
return
# Create a figure and a set of subplots based on the number of selected plot types.
# sharex=True makes all subplots share the same x-axis (time)
# squeeze=False ensures that 'axes' is always a 2D array, even if num_plots is 1.
fig, axes = plt.subplots(num_plots, 1, figsize=(15, 6 * num_plots), sharex=True, squeeze=False)
axes = axes.flatten() # Flatten the 2D array to 1D for easier iteration
fig, axes = plt.subplots(num_plots, 1, figsize=(15, 9 * num_plots), sharex=True, squeeze=False)
axes = axes.flatten()
# --- Loop through selected plot types and generate plots ---
for i, plot_type in enumerate(plot_types):
ax = axes[i]
config = plot_configs[plot_type]
max_data_value = 0
for j, col_name in enumerate(config['cols']):
ax.plot(df['timestamp'], df[col_name], label=channel_labels[j], color=channel_colors[j])
if col_name in df.columns:
max_col_value = df[col_name].max()
if max_col_value > max_data_value:
max_data_value = max_col_value
# --- Apply and plot filtered data ---
if filter_type and window_size:
# Plot original data (lightly)
ax.plot(x_axis_data, df[col_name], label=f'{channel_labels[j]} (Raw)', color=channel_colors[j], alpha=0.5, zorder=2)
# Calculate window size in samples
if avg_interval_s > 0:
window_samples = int(window_size / avg_interval_s)
if window_samples < 1:
window_samples = 1
filtered_data = None
filter_label = ""
if filter_type == 'savgol':
if window_samples % 2 == 0:
window_samples += 1
if window_samples > 2:
filtered_data = savgol_filter(df[col_name], window_samples, 2)
filter_label = "Savitzky-Golay"
elif filter_type == 'moving_average':
filtered_data = df[col_name].rolling(window=window_samples, center=True).mean()
filter_label = "Moving Average"
elif filter_type == 'ema':
filtered_data = df[col_name].ewm(span=window_samples, adjust=False).mean()
filter_label = "Exponential Moving Average"
elif filter_type == 'gaussian':
sigma = window_samples / 4.0
filtered_data = gaussian_filter1d(df[col_name], sigma=sigma)
filter_label = "Gaussian"
if filtered_data is not None:
ax.plot(x_axis_data, filtered_data, label=f'{channel_labels[j]} ({filter_label})', color=channel_colors[j], linestyle='-', linewidth=1.5, zorder=3)
else:
# No filter, plot original data (boldly)
ax.plot(x_axis_data, df[col_name], label=channel_labels[j], color=channel_colors[j], linewidth=1.5, zorder=2)
else:
print(f"Warning: Column '{col_name}' not found in CSV. Skipping.")
# --- Dynamic Y-axis Scaling ---
ax.set_ylim(bottom=0)
y_max_option = y_max_options.get(plot_type)
if y_max_option is not None:
ax.set_ylim(top=y_max_option)
elif plot_type in scale_config:
steps = scale_config[plot_type]['steps']
new_max = next((step for step in steps if step >= max_data_value), steps[-1])
ax.set_ylim(top=new_max)
ax.set_title(config['title'])
ax.set_ylabel(config['ylabel'])
ax.legend()
ax.grid(True, which='both', linestyle='--', linewidth=0.5)
# --- Formatting the x-axis (Time) ---
# Improve date formatting on the x-axis
# Apply formatting to the last subplot's x-axis
# --- Y-Grid and Tick Configuration ---
y_min, y_max = ax.get_ylim()
if y_max <= 0:
major_interval = 1.0 # Default for very small or zero range
elif plot_type == 'current' and y_max <= 2.5:
major_interval = 0.5 # Maintain current behavior for very small current values
elif y_max <= 10:
major_interval = 2.0 # Maintain current behavior for small ranges where 5-unit is too coarse
elif y_max <= 25:
major_interval = 5.0 # Already a multiple of 5
else: # y_max > 25
# Aim for major ticks that are multiples of 5.
# Calculate a rough interval to get around 5 major ticks.
rough_interval = y_max / 5.0
# Find the smallest multiple of 5 that is greater than or equal to rough_interval.
# This ensures labels are multiples of 5.
major_interval = math.ceil(rough_interval / 5.0) * 5.0
# Ensure major_interval is not 0 if y_max is small but positive.
if major_interval == 0 and y_max > 0:
major_interval = 5.0
ax.yaxis.set_major_locator(MultipleLocator(major_interval))
ax.yaxis.set_minor_locator(MultipleLocator(1))
ax.yaxis.grid(False, which='major')
ax.yaxis.grid(True, which='minor', linestyle='--', linewidth=0.6, zorder=0)
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)
# --- X-Grid Configuration ---
ax.xaxis.grid(True, which='major', linestyle='--', linewidth=0.8)
if time_x_line is not None:
ax.xaxis.grid(True, which='minor', linestyle=':', linewidth=0.6)
# --- Formatting the x-axis ---
last_ax = axes[-1]
last_ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S'))
last_ax.xaxis.set_major_locator(plt.MaxNLocator(15)) # Limit the number of ticks
plt.xlabel('Time')
if not df.empty:
last_ax.set_xlim(x_axis_data.iloc[0], x_axis_data.iloc[-1])
if relative_time:
plt.xlabel('Elapsed Time (seconds)')
if time_x_label is not None:
last_ax.xaxis.set_major_locator(MultipleLocator(time_x_label))
else:
last_ax.xaxis.set_major_locator(plt.MaxNLocator(15))
if time_x_line is not None:
last_ax.xaxis.set_minor_locator(MultipleLocator(time_x_line))
else:
local_tz = gettz()
plt.xlabel(f'Time ({local_tz.tzname(df["timestamp"].iloc[-1])})')
last_ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S', tz=local_tz))
if time_x_label is not None:
last_ax.xaxis.set_major_locator(mdates.SecondLocator(interval=int(time_x_label)))
else:
last_ax.xaxis.set_major_locator(plt.MaxNLocator(15))
if time_x_line is not None:
last_ax.xaxis.set_minor_locator(mdates.SecondLocator(interval=int(time_x_line)))
plt.xticks(rotation=45)
# 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')
fig.suptitle(f'ODROID Power Log ({start_time} to {end_time})', fontsize=16, y=0.95)
# --- Add a main title and subtitle ---
if relative_time:
main_title = 'PowerMate Log'
else:
start_time_str = df['timestamp'].iloc[0].strftime('%Y-%m-%d %H:%M:%S')
end_time_str = df['timestamp'].iloc[-1].strftime('%H:%M:%S')
main_title = f'PowerMate Log ({start_time_str} to {end_time_str})'
# 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)
plt.tight_layout(rect=[0, 0, 1, 0.98])
# --- Save the plot to a file ---
try:
@@ -99,9 +287,52 @@ 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."
)
parser.add_argument("--voltage_y_max", type=float, help="Maximum value for the voltage plot's Y-axis.")
parser.add_argument("--current_y_max", type=float, help="Maximum value for the current plot's Y-axis.")
parser.add_argument("--power_y_max", type=float, help="Maximum value for the power plot's Y-axis.")
parser.add_argument(
"-r", "--relative-time",
action='store_true',
help="Display the x-axis as elapsed time from the start (in seconds) instead of absolute time."
)
parser.add_argument("--time_x_line", type=float, help="Interval in seconds for x-axis grid lines.")
parser.add_argument("--time_x_label", type=float, help="Interval in seconds for x-axis labels.")
parser.add_argument(
'--filter',
choices=['savgol', 'moving_average', 'ema', 'gaussian'],
help='The type of filter to apply to the data.'
)
parser.add_argument(
'--window_size',
type=float,
help='The window size for the filter in seconds.'
)
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,
voltage_y_max=args.voltage_y_max,
current_y_max=args.current_y_max,
power_y_max=args.power_y_max,
relative_time=args.relative_time,
time_x_line=args.time_x_line,
time_x_label=args.time_x_label,
filter_type=args.filter,
window_size=args.window_size
)
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,10 @@ import asyncio
import csv
import requests
import websockets
from datetime import datetime
import websockets.asyncio
import websockets.asyncio.client
import websockets.exceptions
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 +73,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 +100,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 +113,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

@@ -0,0 +1,177 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/gorilla/websocket"
"google.golang.org/protobuf/proto"
"odroid-tui/pb"
)
func scanWifi(d ConnectionDetails) tea.Cmd {
return func() tea.Msg {
url := fmt.Sprintf("http://%s/api/wifi/scan", d.IP)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+d.Token)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var list []WifiAP
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
return err
}
return WifiScanListMsg(list)
}
}
func fetchSettings(d ConnectionDetails) tea.Cmd {
return func() tea.Msg {
url := fmt.Sprintf("http://%s/api/setting", d.IP)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+d.Token)
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var cfg SettingsPayload
if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
return err
}
return SettingsFetchedMsg(cfg)
}
}
func saveSettingsMap(d ConnectionDetails, payload interface{}) tea.Cmd {
return func() tea.Msg {
url := fmt.Sprintf("http://%s/api/setting", d.IP)
jsonBytes, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonBytes))
req.Header.Set("Authorization", "Bearer "+d.Token)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("Save Failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("Save Error: %d", resp.StatusCode)
}
return ActionDoneMsg{}
}
}
func rebootDevice(d ConnectionDetails) tea.Cmd {
return func() tea.Msg {
url := fmt.Sprintf("http://%s/api/reboot", d.IP)
req, _ := http.NewRequest("POST", url, nil)
req.Header.Set("Authorization", "Bearer "+d.Token)
client := &http.Client{Timeout: 2 * time.Second}
client.Do(req)
return ActionDoneMsg{}
}
}
func performLogin(d ConnectionDetails) tea.Cmd {
return func() tea.Msg {
url := fmt.Sprintf("http://%s/login", d.IP)
payload := map[string]string{"username": d.Username, "password": d.Password}
jsonBytes, _ := json.Marshal(payload)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Post(url, "application/json", bytes.NewBuffer(jsonBytes))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("Login Error: %d", resp.StatusCode)
}
var res map[string]string
json.NewDecoder(resp.Body).Decode(&res)
return LoginSuccessMsg(res["token"])
}
}
func fetchInitialState(d ConnectionDetails) tea.Cmd {
return func() tea.Msg {
url := fmt.Sprintf("http://%s/api/control", d.IP)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+d.Token)
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
var res InitApiResponse
json.NewDecoder(resp.Body).Decode(&res)
return InitStateMsg{MainOn: res.Load12vOn, UsbOn: res.Load5vOn}
}
}
func toggleLoad(d ConnectionDetails, key string, state bool) tea.Cmd {
return func() tea.Msg {
url := fmt.Sprintf("http://%s/api/control", d.IP)
payload := map[string]interface{}{key: state}
jsonBytes, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonBytes))
req.Header.Set("Authorization", "Bearer "+d.Token)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 1 * time.Second}
client.Do(req)
return nil
}
}
func connectWebSocket(d ConnectionDetails) tea.Cmd {
return func() tea.Msg {
u := fmt.Sprintf("ws://%s/ws?token=%s", d.IP, d.Token)
conn, _, err := websocket.DefaultDialer.Dial(u, nil)
if err != nil {
return err
}
return waitForWebSocketMessage(conn)()
}
}
func waitForWebSocketMessage(conn *websocket.Conn) tea.Cmd {
return func() tea.Msg {
_, message, err := conn.ReadMessage()
if err != nil {
return err
}
var status pb.StatusMessage
if err := proto.Unmarshal(message, &status); err == nil {
conv := func(s *pb.SensorChannelData) *PowerMetrics {
if s == nil {
return nil
}
return &PowerMetrics{Voltage: float64(s.GetVoltage()), Current: float64(s.GetCurrent()), Power: float64(s.GetPower())}
}
msg := WsPartialMsg{Conn: conn}
if s := status.GetSensorData(); s != nil {
msg.Vin = conv(s.GetVin())
msg.Main = conv(s.GetMain())
msg.Usb = conv(s.GetUsb())
}
if w := status.GetWifiStatus(); w != nil {
msg.Wifi = &WifiInfo{SSID: w.GetSsid(), RSSI: int(w.GetRssi())}
}
if sw := status.GetSwStatus(); sw != nil {
msg.Sw = &SwitchInfo{MainOn: sw.GetMain(), UsbOn: sw.GetUsb()}
}
return msg
}
return waitForWebSocketMessage(conn)
}
}

View File

@@ -0,0 +1,173 @@
package main
import (
"fmt"
"github.com/charmbracelet/huh"
)
func (m *model) initLoginForm() {
m.loginForm = huh.NewForm(
huh.NewGroup(
huh.NewNote().Title("ODROID Connector").Description("Enter credentials"),
huh.NewInput().Title("IP Address").Value(&m.formData.IP),
huh.NewInput().Title("Username").Value(&m.formData.Username),
huh.NewInput().Title("Password").Value(&m.formData.Password).EchoMode(huh.EchoModePassword),
),
).WithTheme(huh.ThemeBase())
}
func (m *model) initSettingsMenu() {
*m.menuSelection = "scan"
m.settingsForm = huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Settings Menu").
Key("menu_select").
Options(
huh.NewOption("1. Scan Networks", "scan"),
huh.NewOption("2. Manual Connection", "manual"),
huh.NewOption("3. IP Configuration", "ip"),
huh.NewOption("4. AP Mode Settings", "ap"),
huh.NewOption("5. System (UART/Sensor)", "system"),
huh.NewOption("6. Safety (Current Limits)", "safety"),
huh.NewOption("7. Account Settings", "account"),
huh.NewOption("8. Reboot Device", "reboot"),
huh.NewOption("Esc. Exit", "exit"),
).
Value(m.menuSelection),
),
).WithTheme(huh.ThemeDracula())
}
func (m *model) initWifiScanForm() {
opts := make([]huh.Option[string], 0)
for _, ap := range m.wifiScanList {
label := fmt.Sprintf("%s (%ddBm, %s)", ap.SSID, ap.RSSI, ap.AuthMode)
opts = append(opts, huh.NewOption(label, ap.SSID))
}
if len(opts) == 0 {
opts = append(opts, huh.NewOption("(No Networks Found - Back)", ""))
}
// [수정] 터미널 높이를 계산하여 리스트 최대 높이 제한 (스크롤 활성화)
listHeight := 10
if m.height > 20 {
listHeight = m.height - 10 // 여백 고려
}
m.settingsForm = huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Select Wi-Fi Network").
Key("scan_ssid").
Options(opts...).
Value(&m.settingsData.WifiSSID).
WithHeight(listHeight), // [핵심] 높이 제한으로 터미널 뚫림 방지
),
).WithTheme(huh.ThemeDracula())
}
func (m *model) initWifiForm() {
m.settingsForm = huh.NewForm(
huh.NewGroup(
huh.NewNote().Title("Connect to Wi-Fi"),
huh.NewInput().Title("SSID").Value(&m.settingsData.WifiSSID),
huh.NewInput().Title("Password").Value(&m.settingsData.WifiPass).EchoMode(huh.EchoModePassword),
),
).WithTheme(huh.ThemeDracula())
}
func (m *model) initIPForm() {
m.settingsForm = huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Network Type").
Options(
huh.NewOption("DHCP", "dhcp"),
huh.NewOption("Static", "static"),
).
Value(&m.settingsData.NetType),
),
huh.NewGroup(
huh.NewNote().Title("Static IP Details"),
huh.NewInput().Title("IP Address").Value(&m.settingsData.IP),
huh.NewInput().Title("Gateway").Value(&m.settingsData.Gateway),
huh.NewInput().Title("Subnet Mask").Value(&m.settingsData.Subnet),
huh.NewInput().Title("DNS 1").Value(&m.settingsData.DNS1),
huh.NewInput().Title("DNS 2").Value(&m.settingsData.DNS2),
).WithHideFunc(func() bool {
// [수정] DHCP일 때 완벽히 숨김 처리
return m.settingsData.NetType != "static"
}),
).WithTheme(huh.ThemeDracula())
}
func (m *model) initAPForm() {
m.settingsForm = huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("WiFi Mode").
Options(
huh.NewOption("Station Only (STA)", "sta"),
huh.NewOption("AP + Station (APSTA)", "apsta"),
).
Value(&m.settingsData.Mode),
),
huh.NewGroup(
huh.NewNote().Title("AP Settings"),
huh.NewInput().Title("AP SSID").Value(&m.settingsData.APSSID),
huh.NewInput().Title("AP Password").Value(&m.settingsData.APPass).EchoMode(huh.EchoModePassword),
).WithHideFunc(func() bool {
// [수정] APSTA가 아닐 때 숨김 처리
return m.settingsData.Mode != "apsta"
}),
).WithTheme(huh.ThemeDracula())
}
func (m *model) initSystemForm() {
m.settingsForm = huh.NewForm(
huh.NewGroup(
huh.NewNote().Title("System Configuration"),
huh.NewSelect[string]().
Title("UART Baudrate").
Options(
huh.NewOption("9600", "9600"),
huh.NewOption("19200", "19200"),
huh.NewOption("38400", "38400"),
huh.NewOption("57600", "57600"),
huh.NewOption("115200", "115200"),
huh.NewOption("1500000", "1500000"),
).
Value(&m.settingsData.Baudrate),
huh.NewInput().
Title("Sensor Period (ms)").
Value(&m.settingsData.Period),
),
).WithTheme(huh.ThemeDracula())
}
func (m *model) initSafetyForm() {
vinStr := fmt.Sprintf("%.2f", m.settingsData.VinLimit)
mainStr := fmt.Sprintf("%.2f", m.settingsData.MainLimit)
usbStr := fmt.Sprintf("%.2f", m.settingsData.UsbLimit)
m.settingsForm = huh.NewForm(
huh.NewGroup(
huh.NewNote().Title("Current Limits (Ampere)"),
// Key를 명시하여 update.go에서 안전하게 파싱
huh.NewInput().Title("VIN Limit").Value(&vinStr).Key("vin_limit"),
huh.NewInput().Title("Main (12V) Limit").Value(&mainStr).Key("main_limit"),
huh.NewInput().Title("USB (5V) Limit").Value(&usbStr).Key("usb_limit"),
),
).WithTheme(huh.ThemeDracula())
}
func (m *model) initAccountForm() {
m.settingsForm = huh.NewForm(
huh.NewGroup(
huh.NewNote().Title("Change Credentials"),
huh.NewInput().Title("New Username").Value(&m.settingsData.NewUser),
huh.NewInput().Title("New Password").Value(&m.settingsData.NewPass).EchoMode(huh.EchoModePassword),
),
).WithTheme(huh.ThemeDracula())
}

View File

@@ -0,0 +1,41 @@
module odroid-tui
go 1.24.0
toolchain go1.24.11
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/gorilla/websocket v1.5.3
github.com/guptarohit/asciigraph v0.7.3
golang.org/x/term v0.38.0
google.golang.org/protobuf v1.36.11
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.23.0 // indirect
)

View File

@@ -0,0 +1,83 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/guptarohit/asciigraph v0.7.3 h1:p05XDDn7cBTWiBqWb30mrwxd6oU0claAjqeytllnsPY=
github.com/guptarohit/asciigraph v0.7.3/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=

View File

@@ -0,0 +1,63 @@
package main
import (
"flag"
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
func initialModel(ip, user, pass string) model {
formPtr := &ConnectionDetails{IP: "192.168.0.100", Username: "admin"}
if ip != "" {
formPtr.IP = ip
}
if user != "" {
formPtr.Username = user
}
if pass != "" {
formPtr.Password = pass
}
connPtr := &ConnectionDetails{IP: ip, Username: user, Password: pass}
// [수정] menuSelection 초기화
menuSel := "scan"
m := model{
state: StateLogin,
connDetails: connPtr,
formData: formPtr,
menuSelection: &menuSel, // 포인터 할당
settingsData: SettingsPayload{
Baudrate: "115200",
Period: "1000",
},
}
if ip != "" && user != "" && pass != "" {
return m
}
m.initLoginForm()
return m
}
func (m model) Init() tea.Cmd {
if m.connDetails.IP != "" && m.connDetails.Username != "" && m.connDetails.Password != "" {
return performLogin(*m.connDetails)
}
return m.loginForm.Init()
}
func main() {
host := flag.String("h", "", "IP")
user := flag.String("u", "", "User")
pass := flag.String("p", "", "Pass")
flag.Parse()
if _, err := tea.NewProgram(initialModel(*host, *user, *pass), tea.WithAltScreen()).Run(); err != nil {
fmt.Println("Error:", err)
os.Exit(1)
}
}

View File

@@ -0,0 +1,141 @@
package main
import (
"github.com/charmbracelet/huh"
"github.com/gorilla/websocket"
)
// --- [상수 및 Enum] ---
type AppState int
const (
StateLogin AppState = iota
StateDashboard
StateTerminal
StateSettings
)
type SettingSubState int
const (
SubMenu SettingSubState = iota
SubWifiScanList
SubWifiConnect
SubIPConfig
SubAPMode
SubSystem
SubSafety
SubAccount
)
// --- [데이터 구조체] ---
type ConnectionDetails struct {
IP, Username, Password, Token string
}
type PowerMetrics struct {
Voltage, Current, Power float64
}
type WifiInfo struct {
SSID string
RSSI int
}
type SwitchInfo struct {
MainOn, UsbOn bool
}
type DeviceStatus struct {
Vin PowerMetrics
Main PowerMetrics
Usb PowerMetrics
Wifi WifiInfo
Sw SwitchInfo
}
type InitApiResponse struct {
Load12vOn bool `json:"load_12v_on"`
Load5vOn bool `json:"load_5v_on"`
}
type WifiAP struct {
SSID string `json:"ssid"`
RSSI int `json:"rssi"`
AuthMode string `json:"authmode"`
}
type SettingsPayload struct {
Connected bool `json:"connected,omitempty"`
CurSSID string `json:"ssid,omitempty"`
CurIP struct {
IP string `json:"ip"`
Gateway string `json:"gateway"`
Subnet string `json:"subnet"`
DNS1 string `json:"dns1"`
DNS2 string `json:"dns2"`
} `json:"ip,omitempty"`
WifiSSID string `json:"ssid,omitempty"`
WifiPass string `json:"password,omitempty"`
NetType string `json:"net_type,omitempty"`
IP string `json:"ip,omitempty"`
Gateway string `json:"gateway,omitempty"`
Subnet string `json:"subnet,omitempty"`
DNS1 string `json:"dns1,omitempty"`
DNS2 string `json:"dns2,omitempty"`
Mode string `json:"mode,omitempty"`
APSSID string `json:"ap_ssid,omitempty"`
APPass string `json:"ap_password,omitempty"`
Baudrate string `json:"baudrate,omitempty"`
Period string `json:"period,omitempty"`
VinLimit float64 `json:"vin_current_limit,omitempty"`
MainLimit float64 `json:"main_current_limit,omitempty"`
UsbLimit float64 `json:"usb_current_limit,omitempty"`
NewUser string `json:"new_username,omitempty"`
NewPass string `json:"new_password,omitempty"`
}
// --- [메인 모델] ---
type model struct {
state AppState
connDetails *ConnectionDetails
formData *ConnectionDetails
status DeviceStatus
width int
height int
// Settings State
settingSubState SettingSubState
menuSelection *string // [수정] 포인터로 변경 (메뉴 선택 버그 해결 핵심)
scanning bool // [추가] Wi-Fi 스캔 중 상태 표시
settingsData SettingsPayload
wifiScanList []WifiAP
// Forms
loginForm *huh.Form
settingsForm *huh.Form
err error
}
// --- [Tea Messages] ---
type TerminalFinishedMsg struct{}
type LoginSuccessMsg string
type InitStateMsg SwitchInfo
type SettingsFetchedMsg SettingsPayload
type ActionDoneMsg struct{}
type WifiScanListMsg []WifiAP
type WsPartialMsg struct {
Vin, Main, Usb *PowerMetrics
Wifi *WifiInfo
Sw *SwitchInfo
Conn *websocket.Conn
}

View File

@@ -0,0 +1,545 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.10
// protoc v3.21.12
// source: status.proto
package pb
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// Represents data for a single sensor channel
type SensorChannelData struct {
state protoimpl.MessageState `protogen:"open.v1"`
Voltage float32 `protobuf:"fixed32,1,opt,name=voltage,proto3" json:"voltage,omitempty"`
Current float32 `protobuf:"fixed32,2,opt,name=current,proto3" json:"current,omitempty"`
Power float32 `protobuf:"fixed32,3,opt,name=power,proto3" json:"power,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SensorChannelData) Reset() {
*x = SensorChannelData{}
mi := &file_status_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SensorChannelData) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SensorChannelData) ProtoMessage() {}
func (x *SensorChannelData) ProtoReflect() protoreflect.Message {
mi := &file_status_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SensorChannelData.ProtoReflect.Descriptor instead.
func (*SensorChannelData) Descriptor() ([]byte, []int) {
return file_status_proto_rawDescGZIP(), []int{0}
}
func (x *SensorChannelData) GetVoltage() float32 {
if x != nil {
return x.Voltage
}
return 0
}
func (x *SensorChannelData) GetCurrent() float32 {
if x != nil {
return x.Current
}
return 0
}
func (x *SensorChannelData) GetPower() float32 {
if x != nil {
return x.Power
}
return 0
}
// Contains data for all sensor channels and system info
type SensorData struct {
state protoimpl.MessageState `protogen:"open.v1"`
Usb *SensorChannelData `protobuf:"bytes,1,opt,name=usb,proto3" json:"usb,omitempty"`
Main *SensorChannelData `protobuf:"bytes,2,opt,name=main,proto3" json:"main,omitempty"`
Vin *SensorChannelData `protobuf:"bytes,3,opt,name=vin,proto3" json:"vin,omitempty"`
TimestampMs uint64 `protobuf:"varint,4,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"`
UptimeMs uint64 `protobuf:"varint,5,opt,name=uptime_ms,json=uptimeMs,proto3" json:"uptime_ms,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SensorData) Reset() {
*x = SensorData{}
mi := &file_status_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SensorData) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SensorData) ProtoMessage() {}
func (x *SensorData) ProtoReflect() protoreflect.Message {
mi := &file_status_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SensorData.ProtoReflect.Descriptor instead.
func (*SensorData) Descriptor() ([]byte, []int) {
return file_status_proto_rawDescGZIP(), []int{1}
}
func (x *SensorData) GetUsb() *SensorChannelData {
if x != nil {
return x.Usb
}
return nil
}
func (x *SensorData) GetMain() *SensorChannelData {
if x != nil {
return x.Main
}
return nil
}
func (x *SensorData) GetVin() *SensorChannelData {
if x != nil {
return x.Vin
}
return nil
}
func (x *SensorData) GetTimestampMs() uint64 {
if x != nil {
return x.TimestampMs
}
return 0
}
func (x *SensorData) GetUptimeMs() uint64 {
if x != nil {
return x.UptimeMs
}
return 0
}
// Contains WiFi connection status
type WifiStatus struct {
state protoimpl.MessageState `protogen:"open.v1"`
Connected bool `protobuf:"varint,1,opt,name=connected,proto3" json:"connected,omitempty"`
Ssid string `protobuf:"bytes,2,opt,name=ssid,proto3" json:"ssid,omitempty"`
Rssi int32 `protobuf:"varint,3,opt,name=rssi,proto3" json:"rssi,omitempty"`
IpAddress string `protobuf:"bytes,4,opt,name=ip_address,json=ipAddress,proto3" json:"ip_address,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *WifiStatus) Reset() {
*x = WifiStatus{}
mi := &file_status_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *WifiStatus) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*WifiStatus) ProtoMessage() {}
func (x *WifiStatus) ProtoReflect() protoreflect.Message {
mi := &file_status_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use WifiStatus.ProtoReflect.Descriptor instead.
func (*WifiStatus) Descriptor() ([]byte, []int) {
return file_status_proto_rawDescGZIP(), []int{2}
}
func (x *WifiStatus) GetConnected() bool {
if x != nil {
return x.Connected
}
return false
}
func (x *WifiStatus) GetSsid() string {
if x != nil {
return x.Ssid
}
return ""
}
func (x *WifiStatus) GetRssi() int32 {
if x != nil {
return x.Rssi
}
return 0
}
func (x *WifiStatus) GetIpAddress() string {
if x != nil {
return x.IpAddress
}
return ""
}
// Contains raw UART data
type UartData struct {
state protoimpl.MessageState `protogen:"open.v1"`
Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UartData) Reset() {
*x = UartData{}
mi := &file_status_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UartData) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UartData) ProtoMessage() {}
func (x *UartData) ProtoReflect() protoreflect.Message {
mi := &file_status_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use UartData.ProtoReflect.Descriptor instead.
func (*UartData) Descriptor() ([]byte, []int) {
return file_status_proto_rawDescGZIP(), []int{3}
}
func (x *UartData) GetData() []byte {
if x != nil {
return x.Data
}
return nil
}
// Contains load sw status
type LoadSwStatus struct {
state protoimpl.MessageState `protogen:"open.v1"`
Main bool `protobuf:"varint,1,opt,name=main,proto3" json:"main,omitempty"`
Usb bool `protobuf:"varint,2,opt,name=usb,proto3" json:"usb,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LoadSwStatus) Reset() {
*x = LoadSwStatus{}
mi := &file_status_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LoadSwStatus) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LoadSwStatus) ProtoMessage() {}
func (x *LoadSwStatus) ProtoReflect() protoreflect.Message {
mi := &file_status_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LoadSwStatus.ProtoReflect.Descriptor instead.
func (*LoadSwStatus) Descriptor() ([]byte, []int) {
return file_status_proto_rawDescGZIP(), []int{4}
}
func (x *LoadSwStatus) GetMain() bool {
if x != nil {
return x.Main
}
return false
}
func (x *LoadSwStatus) GetUsb() bool {
if x != nil {
return x.Usb
}
return false
}
// Top-level message for all websocket communication
type StatusMessage struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Payload:
//
// *StatusMessage_SensorData
// *StatusMessage_WifiStatus
// *StatusMessage_SwStatus
// *StatusMessage_UartData
Payload isStatusMessage_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StatusMessage) Reset() {
*x = StatusMessage{}
mi := &file_status_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StatusMessage) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StatusMessage) ProtoMessage() {}
func (x *StatusMessage) ProtoReflect() protoreflect.Message {
mi := &file_status_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StatusMessage.ProtoReflect.Descriptor instead.
func (*StatusMessage) Descriptor() ([]byte, []int) {
return file_status_proto_rawDescGZIP(), []int{5}
}
func (x *StatusMessage) GetPayload() isStatusMessage_Payload {
if x != nil {
return x.Payload
}
return nil
}
func (x *StatusMessage) GetSensorData() *SensorData {
if x != nil {
if x, ok := x.Payload.(*StatusMessage_SensorData); ok {
return x.SensorData
}
}
return nil
}
func (x *StatusMessage) GetWifiStatus() *WifiStatus {
if x != nil {
if x, ok := x.Payload.(*StatusMessage_WifiStatus); ok {
return x.WifiStatus
}
}
return nil
}
func (x *StatusMessage) GetSwStatus() *LoadSwStatus {
if x != nil {
if x, ok := x.Payload.(*StatusMessage_SwStatus); ok {
return x.SwStatus
}
}
return nil
}
func (x *StatusMessage) GetUartData() *UartData {
if x != nil {
if x, ok := x.Payload.(*StatusMessage_UartData); ok {
return x.UartData
}
}
return nil
}
type isStatusMessage_Payload interface {
isStatusMessage_Payload()
}
type StatusMessage_SensorData struct {
SensorData *SensorData `protobuf:"bytes,1,opt,name=sensor_data,json=sensorData,proto3,oneof"`
}
type StatusMessage_WifiStatus struct {
WifiStatus *WifiStatus `protobuf:"bytes,2,opt,name=wifi_status,json=wifiStatus,proto3,oneof"`
}
type StatusMessage_SwStatus struct {
SwStatus *LoadSwStatus `protobuf:"bytes,3,opt,name=sw_status,json=swStatus,proto3,oneof"`
}
type StatusMessage_UartData struct {
UartData *UartData `protobuf:"bytes,4,opt,name=uart_data,json=uartData,proto3,oneof"`
}
func (*StatusMessage_SensorData) isStatusMessage_Payload() {}
func (*StatusMessage_WifiStatus) isStatusMessage_Payload() {}
func (*StatusMessage_SwStatus) isStatusMessage_Payload() {}
func (*StatusMessage_UartData) isStatusMessage_Payload() {}
var File_status_proto protoreflect.FileDescriptor
const file_status_proto_rawDesc = "" +
"\n" +
"\fstatus.proto\"]\n" +
"\x11SensorChannelData\x12\x18\n" +
"\avoltage\x18\x01 \x01(\x02R\avoltage\x12\x18\n" +
"\acurrent\x18\x02 \x01(\x02R\acurrent\x12\x14\n" +
"\x05power\x18\x03 \x01(\x02R\x05power\"\xc0\x01\n" +
"\n" +
"SensorData\x12$\n" +
"\x03usb\x18\x01 \x01(\v2\x12.SensorChannelDataR\x03usb\x12&\n" +
"\x04main\x18\x02 \x01(\v2\x12.SensorChannelDataR\x04main\x12$\n" +
"\x03vin\x18\x03 \x01(\v2\x12.SensorChannelDataR\x03vin\x12!\n" +
"\ftimestamp_ms\x18\x04 \x01(\x04R\vtimestampMs\x12\x1b\n" +
"\tuptime_ms\x18\x05 \x01(\x04R\buptimeMs\"q\n" +
"\n" +
"WifiStatus\x12\x1c\n" +
"\tconnected\x18\x01 \x01(\bR\tconnected\x12\x12\n" +
"\x04ssid\x18\x02 \x01(\tR\x04ssid\x12\x12\n" +
"\x04rssi\x18\x03 \x01(\x05R\x04rssi\x12\x1d\n" +
"\n" +
"ip_address\x18\x04 \x01(\tR\tipAddress\"\x1e\n" +
"\bUartData\x12\x12\n" +
"\x04data\x18\x01 \x01(\fR\x04data\"4\n" +
"\fLoadSwStatus\x12\x12\n" +
"\x04main\x18\x01 \x01(\bR\x04main\x12\x10\n" +
"\x03usb\x18\x02 \x01(\bR\x03usb\"\xd2\x01\n" +
"\rStatusMessage\x12.\n" +
"\vsensor_data\x18\x01 \x01(\v2\v.SensorDataH\x00R\n" +
"sensorData\x12.\n" +
"\vwifi_status\x18\x02 \x01(\v2\v.WifiStatusH\x00R\n" +
"wifiStatus\x12,\n" +
"\tsw_status\x18\x03 \x01(\v2\r.LoadSwStatusH\x00R\bswStatus\x12(\n" +
"\tuart_data\x18\x04 \x01(\v2\t.UartDataH\x00R\buartDataB\t\n" +
"\apayloadB\x0fZ\rodroid-tui/pbb\x06proto3"
var (
file_status_proto_rawDescOnce sync.Once
file_status_proto_rawDescData []byte
)
func file_status_proto_rawDescGZIP() []byte {
file_status_proto_rawDescOnce.Do(func() {
file_status_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_status_proto_rawDesc), len(file_status_proto_rawDesc)))
})
return file_status_proto_rawDescData
}
var file_status_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
var file_status_proto_goTypes = []any{
(*SensorChannelData)(nil), // 0: SensorChannelData
(*SensorData)(nil), // 1: SensorData
(*WifiStatus)(nil), // 2: WifiStatus
(*UartData)(nil), // 3: UartData
(*LoadSwStatus)(nil), // 4: LoadSwStatus
(*StatusMessage)(nil), // 5: StatusMessage
}
var file_status_proto_depIdxs = []int32{
0, // 0: SensorData.usb:type_name -> SensorChannelData
0, // 1: SensorData.main:type_name -> SensorChannelData
0, // 2: SensorData.vin:type_name -> SensorChannelData
1, // 3: StatusMessage.sensor_data:type_name -> SensorData
2, // 4: StatusMessage.wifi_status:type_name -> WifiStatus
4, // 5: StatusMessage.sw_status:type_name -> LoadSwStatus
3, // 6: StatusMessage.uart_data:type_name -> UartData
7, // [7:7] is the sub-list for method output_type
7, // [7:7] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
7, // [7:7] is the sub-list for extension extendee
0, // [0:7] is the sub-list for field type_name
}
func init() { file_status_proto_init() }
func file_status_proto_init() {
if File_status_proto != nil {
return
}
file_status_proto_msgTypes[5].OneofWrappers = []any{
(*StatusMessage_SensorData)(nil),
(*StatusMessage_WifiStatus)(nil),
(*StatusMessage_SwStatus)(nil),
(*StatusMessage_UartData)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_status_proto_rawDesc), len(file_status_proto_rawDesc)),
NumEnums: 0,
NumMessages: 6,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_status_proto_goTypes,
DependencyIndexes: file_status_proto_depIdxs,
MessageInfos: file_status_proto_msgTypes,
}.Build()
File_status_proto = out.File
file_status_proto_goTypes = nil
file_status_proto_depIdxs = nil
}

View File

@@ -0,0 +1,48 @@
syntax = "proto3";
option go_package = "odroid-tui/pb";
// Represents data for a single sensor channel
message SensorChannelData {
float voltage = 1;
float current = 2;
float power = 3;
}
// Contains data for all sensor channels and system info
message SensorData {
SensorChannelData usb = 1;
SensorChannelData main = 2;
SensorChannelData vin = 3;
uint64 timestamp_ms = 4;
uint64 uptime_ms = 5;
}
// Contains WiFi connection status
message WifiStatus {
bool connected = 1;
string ssid = 2;
int32 rssi = 3;
string ip_address = 4;
}
// Contains raw UART data
message UartData {
bytes data = 1;
}
// Contains load sw status
message LoadSwStatus {
bool main = 1;
bool usb = 2;
}
// Top-level message for all websocket communication
message StatusMessage {
oneof payload {
SensorData sensor_data = 1;
WifiStatus wifi_status = 2;
LoadSwStatus sw_status = 3;
UartData uart_data = 4;
}
}

View File

@@ -0,0 +1,43 @@
package main
import "github.com/charmbracelet/lipgloss"
// --- [색상 정의] ---
const (
ColorPurple = lipgloss.Color("#7D56F4")
ColorGreen = lipgloss.Color("#00AF87")
ColorRed = lipgloss.Color("#FF5F87")
ColorGray = lipgloss.Color("#404040")
ColorText = lipgloss.Color("#FAFAFA")
ColorBlue = lipgloss.Color("#335588")
)
// --- [스타일 정의] ---
var (
loginBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(ColorPurple).
Padding(1, 2).
Width(40).
Align(lipgloss.Left)
settingsBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(ColorBlue).
Padding(1, 2).
Width(60).
// [수정] Height, MinHeight 제거: 내용물에 따라 높이 자동 조절 (숨겨진 필드 대응)
Align(lipgloss.Left)
panelBaseStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
Padding(0, 1).
Align(lipgloss.Center)
titleStyle = lipgloss.NewStyle().Bold(true).MarginBottom(1)
lblStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).MarginRight(1)
statusOn = lipgloss.NewStyle().Foreground(ColorGreen).Bold(true)
statusOff = lipgloss.NewStyle().Foreground(ColorRed).Bold(true)
errStyle = lipgloss.NewStyle().Foreground(ColorRed)
)

View File

@@ -0,0 +1,83 @@
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/gorilla/websocket"
"golang.org/x/term"
"google.golang.org/protobuf/proto"
"odroid-tui/pb"
)
func startRawTerminal(m *model) tea.Cmd {
return func() tea.Msg {
fd := int(os.Stdin.Fd())
oldState, err := term.MakeRaw(fd)
if err != nil {
return err
}
defer term.Restore(fd, oldState)
fmt.Print("\033[2J\033[H")
fmt.Printf("\r\n\033[32m--- Terminal Connected (Ctrl+A to exit) ---\033[0m\r\n")
u := fmt.Sprintf("ws://%s/ws?token=%s", m.connDetails.IP, m.connDetails.Token)
ws, _, err := websocket.DefaultDialer.Dial(u, nil)
if err != nil {
return err
}
defer ws.Close()
done := make(chan struct{})
sendChan := make(chan []byte, 256)
go func() {
for d := range sendChan {
ws.WriteMessage(websocket.BinaryMessage, d)
}
}()
go func() {
for {
_, msg, err := ws.ReadMessage()
if err != nil {
close(done)
return
}
var st pb.StatusMessage
if proto.Unmarshal(msg, &st) == nil {
if u := st.GetUartData(); u != nil {
os.Stdout.Write(u.GetData())
}
} else {
os.Stdout.Write(msg)
}
}
}()
buf := make([]byte, 1024)
for {
n, err := os.Stdin.Read(buf)
if err != nil {
close(sendChan)
return TerminalFinishedMsg{}
}
if n > 0 {
for i := 0; i < n; i++ {
if buf[i] == 1 {
ws.Close()
return TerminalFinishedMsg{}
}
}
d := make([]byte, n)
copy(d, buf[:n])
sendChan <- d
}
select {
case <-done:
close(sendChan)
return TerminalFinishedMsg{}
default:
}
}
}
}

View File

@@ -0,0 +1,256 @@
package main
import (
"strconv"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
)
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
return m, tea.Quit
}
if m.state == StateSettings {
if m.scanning {
return m, nil
}
if msg.Type == tea.KeyEsc {
if m.settingSubState != SubMenu {
m.settingSubState = SubMenu
m.initSettingsMenu()
return m, m.settingsForm.Init()
}
m.state = StateDashboard
m.err = nil
return m, nil
}
}
if m.state == StateDashboard {
switch msg.String() {
case "enter":
m.state = StateTerminal
return m, tea.Sequence(tea.ExitAltScreen, startRawTerminal(&m))
case "s":
return m, fetchSettings(*m.connDetails)
case "u":
return m, toggleLoad(*m.connDetails, "load_5v_on", !m.status.Sw.UsbOn)
case "m":
return m, toggleLoad(*m.connDetails, "load_12v_on", !m.status.Sw.MainOn)
case "q":
return m, tea.Quit
}
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case LoginSuccessMsg:
m.connDetails.Token = string(msg)
m.state = StateDashboard
return m, tea.Batch(
connectWebSocket(*m.connDetails),
fetchInitialState(*m.connDetails),
)
case InitStateMsg:
m.status.Sw.MainOn = msg.MainOn
m.status.Sw.UsbOn = msg.UsbOn
return m, nil
case SettingsFetchedMsg:
m.settingsData = SettingsPayload(msg)
if m.settingsData.CurIP.IP != "" {
m.settingsData.IP = m.settingsData.CurIP.IP
m.settingsData.Gateway = m.settingsData.CurIP.Gateway
m.settingsData.Subnet = m.settingsData.CurIP.Subnet
m.settingsData.DNS1 = m.settingsData.CurIP.DNS1
m.settingsData.DNS2 = m.settingsData.CurIP.DNS2
}
m.settingSubState = SubMenu
m.initSettingsMenu()
m.state = StateSettings
return m, m.settingsForm.Init()
case WifiScanListMsg:
m.scanning = false
m.wifiScanList = []WifiAP(msg)
m.settingSubState = SubWifiScanList
m.initWifiScanForm()
return m, m.settingsForm.Init()
case ActionDoneMsg:
m.err = nil
return m, fetchSettings(*m.connDetails)
case WsPartialMsg:
if msg.Vin != nil {
m.status.Vin = *msg.Vin
}
if msg.Main != nil {
m.status.Main = *msg.Main
}
if msg.Usb != nil {
m.status.Usb = *msg.Usb
}
if msg.Wifi != nil {
m.status.Wifi = *msg.Wifi
}
if msg.Sw != nil {
m.status.Sw = *msg.Sw
}
return m, waitForWebSocketMessage(msg.Conn)
case TerminalFinishedMsg:
m.state = StateDashboard
return m, tea.Sequence(tea.EnterAltScreen, tea.ClearScreen)
case error:
m.scanning = false
m.err = msg
if m.state == StateLogin {
if m.loginForm == nil {
m.initLoginForm()
}
return m, m.loginForm.Init()
}
return m, nil
}
// 1. Login Form Update
if m.state == StateLogin && m.loginForm != nil {
form, cmd := m.loginForm.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.loginForm = f
if m.loginForm.State == huh.StateCompleted {
*m.connDetails = *m.formData
return m, performLogin(*m.connDetails)
}
}
return m, cmd
}
// 2. Settings Form Update
if m.state == StateSettings && m.settingsForm != nil {
form, cmd := m.settingsForm.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.settingsForm = f
if m.settingsForm.State == huh.StateCompleted {
if m.settingSubState == SubMenu {
selection := f.GetString("menu_select")
switch selection {
case "scan":
m.scanning = true
return m, scanWifi(*m.connDetails)
case "manual":
m.settingSubState = SubWifiConnect
m.initWifiForm()
case "ip":
m.settingSubState = SubIPConfig
m.initIPForm()
case "ap":
m.settingSubState = SubAPMode
m.initAPForm()
case "system":
m.settingSubState = SubSystem
m.initSystemForm()
case "safety":
m.settingSubState = SubSafety
m.initSafetyForm()
case "account":
m.settingSubState = SubAccount
m.initAccountForm()
case "reboot":
return m, rebootDevice(*m.connDetails)
case "exit":
m.state = StateDashboard
return m, nil
}
return m, m.settingsForm.Init()
}
if m.settingSubState == SubWifiScanList {
selectedSSID := f.GetString("scan_ssid")
if selectedSSID == "" {
m.settingSubState = SubMenu
m.initSettingsMenu()
return m, m.settingsForm.Init()
}
m.settingsData.WifiSSID = selectedSSID
m.settingSubState = SubWifiConnect
m.initWifiForm()
return m, m.settingsForm.Init()
}
switch m.settingSubState {
case SubWifiConnect:
payload := map[string]string{
"ssid": m.settingsData.WifiSSID,
"password": m.settingsData.WifiPass,
}
return m, saveSettingsMap(*m.connDetails, payload)
case SubIPConfig:
payload := map[string]interface{}{"net_type": m.settingsData.NetType}
if m.settingsData.NetType == "static" {
payload["ip"] = m.settingsData.IP
payload["gateway"] = m.settingsData.Gateway
payload["subnet"] = m.settingsData.Subnet
payload["dns1"] = m.settingsData.DNS1
payload["dns2"] = m.settingsData.DNS2
}
return m, saveSettingsMap(*m.connDetails, payload)
case SubAPMode:
payload := map[string]string{"mode": m.settingsData.Mode}
if m.settingsData.Mode == "apsta" {
payload["ap_ssid"] = m.settingsData.APSSID
payload["ap_password"] = m.settingsData.APPass
}
return m, saveSettingsMap(*m.connDetails, payload)
case SubSystem:
// [수정] 백엔드 한계로 인해 순차적(Sequential) 전송 필수
// baudrate 변경 요청 -> period 변경 요청
return m, tea.Sequence(
saveSettingsMap(*m.connDetails, map[string]interface{}{"baudrate": m.settingsData.Baudrate}),
saveSettingsMap(*m.connDetails, map[string]interface{}{"period": m.settingsData.Period}),
)
case SubSafety:
// Safety 설정은 백엔드에서 한 번에 처리가 가능하므로 하나로 묶음
valVin, _ := strconv.ParseFloat(f.GetString("vin_limit"), 64)
valMain, _ := strconv.ParseFloat(f.GetString("main_limit"), 64)
valUsb, _ := strconv.ParseFloat(f.GetString("usb_limit"), 64)
payload := map[string]interface{}{
"vin_current_limit": valVin,
"main_current_limit": valMain,
"usb_current_limit": valUsb,
}
return m, saveSettingsMap(*m.connDetails, payload)
case SubAccount:
payload := map[string]string{
"new_username": m.settingsData.NewUser,
"new_password": m.settingsData.NewPass,
}
return m, saveSettingsMap(*m.connDetails, payload)
}
}
}
return m, cmd
}
return m, nil
}

View File

@@ -0,0 +1,100 @@
package main
import (
"fmt"
"github.com/charmbracelet/lipgloss"
)
func (m model) View() string {
errView := ""
if m.err != nil {
errView = fmt.Sprintf("\n%s", errStyle.Render(fmt.Sprintf("ERROR: %v", m.err)))
}
if m.state == StateLogin {
content := ""
if m.loginForm != nil {
content = m.loginForm.View()
}
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
loginBoxStyle.Render(content+errView))
}
if m.state == StateSettings {
// [수정] 스캔 중이면 로딩 화면 표시
if m.scanning {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
settingsBoxStyle.Render("Scanning Wi-Fi Networks...\nPlease wait."))
}
content := ""
if m.settingsForm != nil {
content = m.settingsForm.View()
}
helpMsg := "\n[Enter] Select/Save [Esc] Back/Cancel"
box := settingsBoxStyle.Render(content + lipgloss.NewStyle().Foreground(ColorGray).Render(helpMsg))
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box+errView)
}
if m.state == StateTerminal {
return ""
}
// Dashboard
wifiTxt := "WiFi: Disconnected"
if m.status.Wifi.SSID != "" {
wifiTxt = fmt.Sprintf("WiFi: %s (%ddBm)", m.status.Wifi.SSID, m.status.Wifi.RSSI)
}
header := lipgloss.NewStyle().Width(m.width).Background(ColorPurple).Foreground(ColorText).Padding(0, 1).Bold(true).Render(wifiTxt)
panelW := (m.width - 6) / 3
if panelW < 22 {
panelW = 22
}
currPanelStyle := panelBaseStyle.Copy().Width(panelW)
renderMetric := func(label string, val float64, unit string, color lipgloss.Color) string {
return fmt.Sprintf("%s%s", lblStyle.Render(label), lipgloss.NewStyle().Foreground(color).Bold(true).Render(fmt.Sprintf("%6.2f %s", val, unit)))
}
pVin := currPanelStyle.Copy().BorderForeground(ColorBlue).Render(lipgloss.JoinVertical(lipgloss.Center,
titleStyle.Foreground(ColorBlue).Render("VIN (Input)"),
renderMetric("VOLT:", m.status.Vin.Voltage, "V", ColorBlue),
renderMetric("CURR:", m.status.Vin.Current, "A", ColorBlue),
renderMetric("POWR:", m.status.Vin.Power, "W", ColorBlue),
))
mColor := ColorRed
mTitle := "MAIN [OFF]"
if m.status.Sw.MainOn {
mColor = ColorGreen
mTitle = "MAIN [ON]"
}
pMain := currPanelStyle.Copy().BorderForeground(mColor).Render(lipgloss.JoinVertical(lipgloss.Center,
titleStyle.Foreground(mColor).Render(mTitle),
renderMetric("VOLT:", m.status.Main.Voltage, "V", mColor),
renderMetric("CURR:", m.status.Main.Current, "A", mColor),
renderMetric("POWR:", m.status.Main.Power, "W", mColor),
))
uColor := ColorRed
uTitle := "USB [OFF]"
if m.status.Sw.UsbOn {
uColor = ColorGreen
uTitle = "USB [ON]"
}
pUsb := currPanelStyle.Copy().BorderForeground(uColor).Render(lipgloss.JoinVertical(lipgloss.Center,
titleStyle.Foreground(uColor).Render(uTitle),
renderMetric("VOLT:", m.status.Usb.Voltage, "V", uColor),
renderMetric("CURR:", m.status.Usb.Current, "A", uColor),
renderMetric("POWR:", m.status.Usb.Power, "W", uColor),
))
panels := lipgloss.PlaceHorizontal(m.width, lipgloss.Center, lipgloss.JoinHorizontal(lipgloss.Top, pVin, pMain, pUsb))
help := lipgloss.NewStyle().Foreground(ColorGray).Render(" [Enter] Terminal [s] Settings [u] Toggle USB [m] Toggle Main [q] Quit")
footer := lipgloss.PlaceHorizontal(m.width, lipgloss.Center, help)
return lipgloss.JoinVertical(lipgloss.Left, header, "\n\n", panels, "\n\n", footer+errView)
}

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"
@@ -53,9 +54,9 @@ ina3221_t ina3221 = {
.ch1 = true, // channel 1 enable
.ch2 = true, // channel 2 enable
.ch3 = true, // channel 3 enable
.avg = INA3221_AVG_64, // 64 samples average
.vbus = INA3221_CT_2116, // 2ms by channel (bus)
.vsht = INA3221_CT_2116, // 2ms by channel (shunt)
.avg = INA3221_AVG_16, // 16 samples average
.vbus = INA3221_CT_140, // 140us by channel (bus)
.vsht = INA3221_CT_1100, // 1.1ms by channel (shunt)
},
};
@@ -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 < 100 || period > 10000) // 0.1 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,12 +180,17 @@ 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");
cJSON* new_username_item = cJSON_GetObjectItem(root, "new_username");
cJSON* new_password_item = cJSON_GetObjectItem(root, "new_password");
bool action_taken = false;
cJSON* resp_root = cJSON_CreateObject();
if (mode_item && cJSON_IsString(mode_item))
{
const char* mode = mode_item->valuestring;
@@ -196,12 +207,6 @@ static esp_err_t setting_post_handler(httpd_req_t* req)
{
nconfig_write(AP_SSID, ap_ssid_item->valuestring);
}
else
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "AP SSID required for APSTA mode");
cJSON_Delete(root);
return ESP_FAIL;
}
if (ap_pass_item && cJSON_IsString(ap_pass_item))
{
@@ -209,19 +214,17 @@ static esp_err_t setting_post_handler(httpd_req_t* req)
}
else
{
nconfig_delete(AP_PASSWORD); // Open network
nconfig_delete(AP_PASSWORD);
}
}
wifi_switch_mode(mode);
httpd_resp_sendstr(req, "{\"status\":\"mode_switch_initiated\"}");
}
else
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid mode");
cJSON_AddStringToObject(resp_root, "mode_status", "initiated");
action_taken = true;
}
}
else if (net_type_item && cJSON_IsString(net_type_item))
if (net_type_item && cJSON_IsString(net_type_item))
{
const char* type = net_type_item->valuestring;
ESP_LOGI(TAG, "Received network config: %s", type);
@@ -253,43 +256,52 @@ static esp_err_t setting_post_handler(httpd_req_t* req)
nconfig_delete(NETIF_DNS2);
wifi_use_static(ip, gw, sn, d1, d2);
httpd_resp_sendstr(req, "{\"status\":\"static_config_applied\"}");
}
else
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing static IP fields");
cJSON_AddStringToObject(resp_root, "net_status", "static_applied");
action_taken = true;
}
}
else if (strcmp(type, "dhcp") == 0)
{
nconfig_write(NETIF_TYPE, "dhcp");
wifi_use_dhcp();
httpd_resp_sendstr(req, "{\"status\":\"dhcp_config_applied\"}");
cJSON_AddStringToObject(resp_root, "net_status", "dhcp_applied");
action_taken = true;
}
}
else if (ssid_item && cJSON_IsString(ssid_item))
// 3. WiFi Connect - [수정] else if -> if
if (ssid_item && cJSON_IsString(ssid_item))
{
cJSON* pass_item = cJSON_GetObjectItem(root, "password");
if (cJSON_IsString(pass_item))
{
httpd_resp_sendstr(req, "{\"status\":\"connection_initiated\"}");
wifi_sta_set_ap(ssid_item->valuestring, pass_item->valuestring);
}
else
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Password required");
cJSON_AddStringToObject(resp_root, "wifi_status", "connecting");
action_taken = true;
}
}
else if (baud_item && cJSON_IsString(baud_item))
// 4. Baudrate - [수정] else if -> if
if (baud_item && cJSON_IsString(baud_item))
{
const char* baudrate = baud_item->valuestring;
ESP_LOGI(TAG, "Received baudrate set request: %s", baudrate);
nconfig_write(UART_BAUD_RATE, baudrate);
change_baud_rate(strtol(baudrate, NULL, 10));
httpd_resp_sendstr(req, "{\"status\":\"baudrate_updated\"}");
cJSON_AddStringToObject(resp_root, "baudrate_status", "updated");
action_taken = true;
}
else if (vin_climit_item || main_climit_item || usb_climit_item)
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));
cJSON_AddStringToObject(resp_root, "period_status", "updated");
action_taken = true;
}
if (vin_climit_item || main_climit_item || usb_climit_item)
{
char num_buf[10];
if (vin_climit_item && cJSON_IsNumber(vin_climit_item))
@@ -322,10 +334,12 @@ static esp_err_t setting_post_handler(httpd_req_t* req)
climit_set_usb(val);
}
}
httpd_resp_sendstr(req, "{\"status\":\"current_limit_updated\"}");
cJSON_AddStringToObject(resp_root, "climit_status", "updated");
action_taken = true;
}
else if (new_username_item && cJSON_IsString(new_username_item) && new_password_item &&
cJSON_IsString(new_password_item))
if (new_username_item && cJSON_IsString(new_username_item) && new_password_item &&
cJSON_IsString(new_password_item))
{
const char* new_username = new_username_item->valuestring;
const char* new_password = new_password_item->valuestring;
@@ -333,13 +347,23 @@ static esp_err_t setting_post_handler(httpd_req_t* req)
nconfig_write(PAGE_USERNAME, new_username);
nconfig_write(PAGE_PASSWORD, new_password);
ESP_LOGI(TAG, "Username and password updated successfully.");
httpd_resp_sendstr(req, "{\"status\":\"user_credentials_updated\"}");
cJSON_AddStringToObject(resp_root, "auth_status", "updated");
action_taken = true;
}
if (action_taken)
{
cJSON_AddStringToObject(resp_root, "status", "ok");
char* resp_str = cJSON_PrintUnformatted(resp_root);
httpd_resp_sendstr(req, resp_str);
free(resp_str);
}
else
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid payload");
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid payload or no known parameters");
}
cJSON_Delete(resp_root);
cJSON_Delete(root);
return ESP_OK;
}

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="100" 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;
@@ -190,7 +202,7 @@ async function handleUserSettingsSubmit(event) {
try {
const response = await api.updateUserSettings(newUsername, newPassword);
if (response && response.status === 'user_credentials_updated') {
if (response && (response.status === 'ok' || response.auth_status === 'updated')) {
alert('Username and password updated successfully. Please log in again with new credentials.');
handleLogout(); // Force logout to re-authenticate with new credentials
} else {
@@ -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

@@ -184,7 +184,7 @@ export async function connectToWifi() {
try {
const result = await api.postWifiConnect(ssid, password);
if (result.status === 'connection_initiated') {
if (result.status === 'ok' || result.wifi_status === 'connecting') {
wifiModal.hide();
setTimeout(() => {
alert(`Connection to "${ssid}" initiated. The device will try to reconnect. Please check the Wi-Fi status icon.`);
@@ -301,6 +301,24 @@ export async function applyBaudRateSettings() {
}
}
/**
* Applies the selected sensor period by sending it to the server.
*/
export async function applyPeriodSettings() {
const period = dom.periodSlider.value;
dom.periodApplyButton.disabled = true;
dom.periodApplyButton.innerHTML = `<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Applying...`;
try {
await api.postPeriodSetting(period);
} catch (error) {
console.error('Error applying period:', error);
} finally {
dom.periodApplyButton.disabled = false;
dom.periodApplyButton.innerHTML = 'Apply';
}
}
/**
* Fetches and displays the current network and device settings in the settings modal.
*/
@@ -338,6 +356,10 @@ export async function initializeSettings() {
if (data.baudrate) {
dom.baudRateSelect.value = data.baudrate;
}
if (data.period) {
dom.periodSlider.value = data.period;
dom.periodValue.textContent = data.period;
}
} catch (error) {
console.error('Error initializing settings:', error);

View File

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

View File

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