16 Commits

Author SHA1 Message Date
9de1cd249b WIP: new terminal-based powermate frontend
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-16 06:16:48 +09:00
83ba6d4e42 setting: fixed an issue where multiple configuration items were not applied.
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-16 06:12:09 +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
20 changed files with 1993 additions and 89 deletions

View File

@@ -42,7 +42,7 @@ Ensure you have Python 3 installed.
With the virtual environment activated, install the necessary Python packages:
```bash
pip3 install requests websockets protobuf pandas matplotlib python-dateutil
pip3 install requests websockets protobuf pandas matplotlib python-dateutil scipy
```
### 4. Protobuf Generated File

View File

@@ -1,32 +1,58 @@
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
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, sources):
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']).
sources (list): A list of strings indicating which power sources to plot
(e.g., ['vin', 'main', 'usb']).
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
df = pd.read_csv(csv_path, parse_dates=['timestamp'])
print(f"Successfully loaded {len(df)} records from '{csv_path}'")
# --- Timezone Conversion ---
local_tz = gettz()
df['timestamp'] = df['timestamp'].dt.tz_convert(local_tz)
print(f"Timestamp converted to local timezone: {local_tz}")
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.")
@@ -36,10 +62,17 @@ def plot_power_data(csv_path, output_path, plot_types, sources):
return
# --- Calculate Average Interval ---
avg_interval_ms = 0
avg_interval_s = 0
if len(df) > 1:
avg_interval = df['timestamp'].diff().mean()
avg_interval_ms = avg_interval.total_seconds() * 1000
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 = {}
@@ -50,15 +83,20 @@ def plot_power_data(csv_path, output_path, plot_types, sources):
# --- Plotting Configuration ---
scale_config = {
'power': {'steps': [5, 20, 50, 160]},
'voltage': {'steps': [5, 10, 15, 25]},
'current': {'steps': [1, 2.5, 5, 10]}
'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]}
}
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'}
@@ -79,16 +117,56 @@ def plot_power_data(csv_path, output_path, plot_types, sources):
max_data_value = 0
for j, col_name in enumerate(config['cols']):
if col_name in df.columns:
ax.plot(df['timestamp'], df[col_name], label=channel_labels[j], color=channel_colors[j], zorder=2)
max_col_value = df[col_name].max()
if max_col_value > max_data_value:
max_data_value = max_col_value
# --- 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)
if plot_type in scale_config:
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)
@@ -97,27 +175,35 @@ def plot_power_data(csv_path, output_path, plot_types, sources):
ax.set_ylabel(config['ylabel'])
ax.legend()
# --- Grid and Tick Configuration ---
# --- Y-Grid and Tick Configuration ---
y_min, y_max = ax.get_ylim()
# Keep the dynamic major_interval logic for tick LABELS
if plot_type == 'current' and y_max <= 2.5:
major_interval = 0.5
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
major_interval = 2.0 # Maintain current behavior for small ranges where 5-unit is too coarse
elif y_max <= 25:
major_interval = 5
else:
major_interval = y_max / 5.0
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))
# Disable the default major grid, but keep the minor one
ax.yaxis.grid(False, which='major')
ax.yaxis.grid(True, which='minor', linestyle='--', linewidth=0.6, zorder=0)
# Draw custom lines for 5 and 10 multiples, which are now the only major grid lines
for y_val in range(int(y_min), int(y_max) + 1):
if y_val == 0: continue
if y_val % 10 == 0:
@@ -125,43 +211,60 @@ def plot_power_data(csv_path, output_path, plot_types, sources):
elif y_val % 5 == 0:
ax.axhline(y=y_val, color='midnightblue', linestyle='--', linewidth=1.2, zorder=1)
# Keep the x-axis grid
# --- 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 (Time) ---
local_tz = gettz()
# --- Formatting the x-axis ---
last_ax = axes[-1]
if not df.empty:
last_ax.set_xlim(df['timestamp'].iloc[0], df['timestamp'].iloc[-1])
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)))
last_ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S', tz=local_tz))
last_ax.xaxis.set_major_locator(plt.MaxNLocator(15))
plt.xlabel(f'Time ({local_tz.tzname(df["timestamp"].iloc[-1])})')
plt.xticks(rotation=45)
# --- Add a main title and subtitle ---
start_time = df['timestamp'].iloc[0].strftime('%Y-%m-%d %H:%M:%S')
end_time = df['timestamp'].iloc[-1].strftime('%H:%M:%S')
main_title = f'PowerMate Log ({start_time} to {end_time})'
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})'
subtitle_parts = []
if avg_interval_ms > 0:
subtitle_parts.append(f'Avg. Interval: {avg_interval_ms:.2f} ms')
voltage_strings = [f'{source.upper()} Avg: {avg_v:.2f} V' for source, avg_v in avg_voltages.items()]
if voltage_strings:
subtitle_parts.extend(voltage_strings)
subtitle = ' | '.join(subtitle_parts)
full_title = main_title
if subtitle:
full_title += f'\n{subtitle}'
fig.suptitle(full_title, fontsize=14)
# Adjust layout to make space for the subtitle
plt.tight_layout(rect=[0, 0, 1, 0.98])
# --- Save the plot to a file ---
@@ -192,9 +295,44 @@ def main():
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, args.source)
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__":

View File

@@ -3,6 +3,9 @@ import asyncio
import csv
import requests
import websockets
import websockets.asyncio
import websockets.asyncio.client
import websockets.exceptions
from datetime import datetime, timezone
# Import the status_pb2.py file generated by `protoc`.

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

@@ -54,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)
},
};
@@ -298,7 +298,7 @@ void init_status_monitor()
esp_err_t update_sensor_period(int period)
{
if (period < 500 || period > 10000) // 0.5 sec ~ 10 sec
if (period < 100 || period > 10000) // 0.1 sec ~ 10 sec
{
return ESP_ERR_INVALID_ARG;
}

View File

@@ -187,6 +187,10 @@ static esp_err_t setting_post_handler(httpd_req_t* req)
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;
@@ -203,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))
{
@@ -216,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);
@@ -260,50 +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 (period_item && cJSON_IsString(period_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));
httpd_resp_sendstr(req, "{\"status\":\"period_updated\"}");
cJSON_AddStringToObject(resp_root, "period_status", "updated");
action_taken = true;
}
else if (vin_climit_item || main_climit_item || usb_climit_item)
if (vin_climit_item || main_climit_item || usb_climit_item)
{
char num_buf[10];
if (vin_climit_item && cJSON_IsNumber(vin_climit_item))
@@ -336,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;
@@ -347,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

@@ -379,7 +379,7 @@
</div>
<div class="mb-3 p-3 border rounded">
<label for="period-slider" class="form-label">Sensor Period: <span class="fw-bold text-primary" id="period-value">...</span> ms</label>
<input type="range" class="form-range" id="period-slider" min="500" max="5000" step="100">
<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>

View File

@@ -202,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 {

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.`);