16 Commits

Author SHA1 Message Date
cb5b9c7d5e console-frontend: add settings management UI and device API integration
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-12 18:40:24 +09:00
edf6cf40cb (WIP) console-frontend: initial implementation of TUI dashboard, API client, WebSocket handling, and protocol buffer support
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-12 18:31:20 +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
10 changed files with 1887 additions and 51 deletions

View File

@@ -0,0 +1,35 @@
module console-frontend
go 1.24.0
toolchain go1.24.11
require (
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/gorilla/websocket v1.5.3
google.golang.org/protobuf v1.36.10
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // 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-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 // 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/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.36.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

View File

@@ -0,0 +1,55 @@
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/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
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/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-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
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/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY=
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
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/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-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=

View File

@@ -0,0 +1,909 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"console-frontend/pb"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/gorilla/websocket"
"github.com/hinshun/vt10x"
"google.golang.org/protobuf/proto"
)
// --- Configuration ---
const DefaultBaseURL = "http://192.168.4.1"
// Global HTTP Client for reuse
var httpClient = &http.Client{
Timeout: 5 * time.Second,
}
// --- Styles ---
var (
titleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFDF5")).
Background(lipgloss.Color("#25A065")).
Padding(0, 1)
infoStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFDF5")).
Padding(0, 1)
headerStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("205")).
Bold(true)
statusBarStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFDF5")).
Background(lipgloss.Color("#3C3C3C")).
Padding(0, 1)
commandModeStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#D75F00")).
Padding(0, 1).
Bold(true)
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
// Help Window Style
helpBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#874BFD")).
Padding(1, 2).
Background(lipgloss.Color("#1e1e1e"))
// Settings Style
settingItemStyle = lipgloss.NewStyle().PaddingLeft(2)
selectedItemStyle = lipgloss.NewStyle().PaddingLeft(0).Foreground(lipgloss.Color("170")).Bold(true).SetString("> ")
)
// --- Messages ---
type sessionMsg struct {
token string
}
type initStatusMsg struct {
mainOn bool
usbOn bool
}
type settingsMsg struct {
settings DeviceSettings
}
type errMsg error
type wsMsg *pb.StatusMessage
// --- Data Structures ---
// DeviceSettings maps to GET/POST /api/setting
type DeviceSettings struct {
VinCurrentLimit float64 `json:"vin_current_limit,omitempty"`
MainCurrentLimit float64 `json:"main_current_limit,omitempty"`
UsbCurrentLimit float64 `json:"usb_current_limit,omitempty"`
BaudRate int `json:"baud_rate,omitempty"`
SensorPeriod int `json:"sensor_period,omitempty"`
}
// --- API Client ---
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type LoginResponse struct {
Token string `json:"token"`
}
type ControlRequest struct {
Load12vOn *bool `json:"load_12v_on,omitempty"`
Load5vOn *bool `json:"load_5v_on,omitempty"`
PowerTrigger *bool `json:"power_trigger,omitempty"`
ResetTrigger *bool `json:"reset_trigger,omitempty"`
}
type ControlStatusResponse struct {
Load12vOn bool `json:"load_12v_on"`
Load5vOn bool `json:"load_5v_on"`
}
func login(baseURL, username, password string) tea.Cmd {
return func() tea.Msg {
baseURL = strings.TrimRight(baseURL, "/")
reqBody, _ := json.Marshal(LoginRequest{Username: username, Password: password})
resp, err := httpClient.Post(baseURL+"/login", "application/json", bytes.NewBuffer(reqBody))
if err != nil {
return errMsg(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return errMsg(fmt.Errorf("login failed: %s", string(body)))
}
var loginResp LoginResponse
if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil {
return errMsg(err)
}
return sessionMsg{token: loginResp.Token}
}
}
func fetchControlStatus(baseURL, token string) tea.Cmd {
return func() tea.Msg {
baseURL = strings.TrimRight(baseURL, "/")
req, _ := http.NewRequest("GET", baseURL+"/api/control", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := httpClient.Do(req)
if err != nil {
return errMsg(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return errMsg(fmt.Errorf("failed to fetch status: %d", resp.StatusCode))
}
var statusResp ControlStatusResponse
if err := json.NewDecoder(resp.Body).Decode(&statusResp); err != nil {
return errMsg(err)
}
return initStatusMsg{
mainOn: statusResp.Load12vOn,
usbOn: statusResp.Load5vOn,
}
}
}
func fetchDeviceSettings(baseURL, token string) tea.Cmd {
return func() tea.Msg {
baseURL = strings.TrimRight(baseURL, "/")
req, _ := http.NewRequest("GET", baseURL+"/api/setting", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := httpClient.Do(req)
if err != nil {
return errMsg(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return errMsg(fmt.Errorf("failed to fetch settings: %d", resp.StatusCode))
}
var s DeviceSettings
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
return errMsg(err)
}
return settingsMsg{settings: s}
}
}
func saveDeviceSetting(baseURL, token string, setting DeviceSettings) tea.Cmd {
return func() tea.Msg {
baseURL = strings.TrimRight(baseURL, "/")
reqBody, _ := json.Marshal(setting)
req, _ := http.NewRequest("POST", baseURL+"/api/setting", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return errMsg(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return errMsg(fmt.Errorf("save failed: %d", resp.StatusCode))
}
// Refresh settings after save
return fetchDeviceSettings(baseURL, token)()
}
}
func rebootDevice(baseURL, token string) tea.Cmd {
return func() tea.Msg {
baseURL = strings.TrimRight(baseURL, "/")
req, _ := http.NewRequest("POST", baseURL+"/api/reboot", nil)
req.Header.Set("Authorization", "Bearer "+token)
httpClient.Do(req) // Don't wait for response too strictly as device reboots
return nil
}
}
func sendControl(baseURL, token string, payload ControlRequest) tea.Cmd {
return func() tea.Msg {
baseURL = strings.TrimRight(baseURL, "/")
reqBody, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", baseURL+"/api/control", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return errMsg(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return errMsg(fmt.Errorf("control failed (%d): %s", resp.StatusCode, string(body)))
}
return nil
}
}
// --- WebSocket Handling ---
func connectWebSocket(baseURL, token string, sendChan <-chan []byte) tea.Cmd {
return func() tea.Msg {
u, err := url.Parse(baseURL)
if err != nil {
return errMsg(err)
}
scheme := "ws"
if u.Scheme == "https" {
scheme = "wss"
}
wsURL := fmt.Sprintf("%s://%s/ws?token=%s", scheme, u.Host, token)
c, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
return errMsg(err)
}
go func() {
for data := range sendChan {
err := c.WriteMessage(websocket.BinaryMessage, data)
if err != nil {
return
}
}
c.Close()
}()
go func() {
defer c.Close()
for {
_, message, err := c.ReadMessage()
if err != nil {
return
}
var statusMsg pb.StatusMessage
if err := proto.Unmarshal(message, &statusMsg); err == nil {
program.Send(wsMsg(&statusMsg))
}
}
}()
return nil
}
}
var program *tea.Program
// --- Model ---
type state int
const (
stateLogin state = iota
stateDashboard
stateSettings
)
// Setting Item Types
const (
SetVinLimit = iota
SetMainLimit
SetUsbLimit
SetBaudRate
SetPeriod
SetReboot
)
type model struct {
state state
baseURL string
token string
width int
height int
err error
wsSend chan []byte
// Login
serverInput textinput.Model
usernameInput textinput.Model
passwordInput textinput.Model
focusIndex int
// Dashboard
sensorData *pb.SensorData
wifiStatus *pb.WifiStatus
swStatus *pb.LoadSwStatus
term vt10x.Terminal
logViewport viewport.Model
awaitingCommand bool
lastStatusMsg string
// Settings
deviceSettings DeviceSettings
settingCursor int
settingEditing bool // True if currently typing a value
settingInput textinput.Model // Shared input for editing values
}
func initialModel() model {
s := textinput.New()
s.Placeholder = "http://192.168.4.1"
s.SetValue(DefaultBaseURL)
s.Focus()
u := textinput.New()
u.Placeholder = "Username"
p := textinput.New()
p.Placeholder = "Password"
p.EchoMode = textinput.EchoPassword
vp := viewport.New(80, 24)
vp.SetContent("Waiting for data...")
// vt10x 초기화 (Writer는 필요 없으므로 Discard)
term := vt10x.New(vt10x.WithWriter(io.Discard))
term.Resize(80, 24)
si := textinput.New()
si.CharLimit = 10
return model{
state: stateLogin,
serverInput: s,
usernameInput: u,
passwordInput: p,
logViewport: vp,
term: term,
swStatus: &pb.LoadSwStatus{Main: false, Usb: false},
wsSend: make(chan []byte, 100),
settingInput: si,
}
}
func (m model) Init() tea.Cmd {
return textinput.Blink
}
func (m model) renderVt10x() string {
var sb strings.Builder
rows := m.logViewport.Height
cols := m.logViewport.Width
sb.Grow(rows * cols * 4)
cursor := m.term.Cursor()
curFG := vt10x.DefaultFG
curBG := vt10x.DefaultBG
sb.WriteString("\x1b[0m")
for y := 0; y < rows; y++ {
for x := 0; x < cols; x++ {
cell := m.term.Cell(x, y)
fg := cell.FG
bg := cell.BG
char := cell.Char
isCursor := (x == cursor.X && y == cursor.Y)
if fg != curFG || bg != curBG || isCursor {
sb.WriteString("\x1b[0m")
if isCursor {
sb.WriteString("\x1b[7m")
}
if fg != vt10x.DefaultFG {
fmt.Fprintf(&sb, "\x1b[38;5;%dm", fg)
}
if bg != vt10x.DefaultBG {
fmt.Fprintf(&sb, "\x1b[48;5;%dm", bg)
}
curFG = fg
curBG = bg
if isCursor {
curFG = vt10x.Color(65535)
curBG = vt10x.Color(65535)
}
}
sb.WriteRune(char)
}
if y < rows-1 {
sb.WriteString("\x1b[0m\n")
curFG = vt10x.DefaultFG
curBG = vt10x.DefaultBG
}
}
return sb.String()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if m.state == stateLogin {
if msg.Type == tea.KeyCtrlC {
return m, tea.Quit
}
switch msg.String() {
case "tab":
m.focusIndex = (m.focusIndex + 1) % 3
m.serverInput.Blur()
m.usernameInput.Blur()
m.passwordInput.Blur()
switch m.focusIndex {
case 0:
m.serverInput.Focus()
case 1:
m.usernameInput.Focus()
case 2:
m.passwordInput.Focus()
}
return m, textinput.Blink
case "enter":
m.baseURL = m.serverInput.Value()
if m.baseURL == "" {
m.baseURL = DefaultBaseURL
}
return m, login(m.baseURL, m.usernameInput.Value(), m.passwordInput.Value())
case "esc":
return m, tea.Quit
default:
if m.focusIndex == 0 {
m.serverInput, cmd = m.serverInput.Update(msg)
cmds = append(cmds, cmd)
} else if m.focusIndex == 1 {
m.usernameInput, cmd = m.usernameInput.Update(msg)
cmds = append(cmds, cmd)
} else {
m.passwordInput, cmd = m.passwordInput.Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
} else if m.state == stateSettings {
// SETTINGS STATE
if m.settingEditing {
switch msg.Type {
case tea.KeyEnter:
// Save value
m.settingEditing = false
val := m.settingInput.Value()
var cmd tea.Cmd
switch m.settingCursor {
case SetVinLimit:
v, _ := strconv.ParseFloat(val, 64)
cmd = saveDeviceSetting(m.baseURL, m.token, DeviceSettings{VinCurrentLimit: v})
case SetMainLimit:
v, _ := strconv.ParseFloat(val, 64)
cmd = saveDeviceSetting(m.baseURL, m.token, DeviceSettings{MainCurrentLimit: v})
case SetUsbLimit:
v, _ := strconv.ParseFloat(val, 64)
cmd = saveDeviceSetting(m.baseURL, m.token, DeviceSettings{UsbCurrentLimit: v})
case SetBaudRate:
v, _ := strconv.Atoi(val)
cmd = saveDeviceSetting(m.baseURL, m.token, DeviceSettings{BaudRate: v})
case SetPeriod:
v, _ := strconv.Atoi(val)
cmd = saveDeviceSetting(m.baseURL, m.token, DeviceSettings{SensorPeriod: v})
}
return m, cmd
case tea.KeyEsc:
m.settingEditing = false
return m, nil
default:
m.settingInput, cmd = m.settingInput.Update(msg)
return m, cmd
}
} else {
// Navigation
switch msg.String() {
case "q", "esc":
m.state = stateDashboard
return m, nil
case "up":
if m.settingCursor > 0 {
m.settingCursor--
}
case "down":
if m.settingCursor < 5 { // items count - 1
m.settingCursor++
}
case "enter":
if m.settingCursor == SetReboot {
return m, rebootDevice(m.baseURL, m.token)
}
// Start editing
m.settingEditing = true
m.settingInput.Focus()
// Pre-fill value
switch m.settingCursor {
case SetVinLimit:
m.settingInput.SetValue(fmt.Sprintf("%.1f", m.deviceSettings.VinCurrentLimit))
case SetMainLimit:
m.settingInput.SetValue(fmt.Sprintf("%.1f", m.deviceSettings.MainCurrentLimit))
case SetUsbLimit:
m.settingInput.SetValue(fmt.Sprintf("%.1f", m.deviceSettings.UsbCurrentLimit))
case SetBaudRate:
m.settingInput.SetValue(fmt.Sprintf("%d", m.deviceSettings.BaudRate))
case SetPeriod:
m.settingInput.SetValue(fmt.Sprintf("%d", m.deviceSettings.SensorPeriod))
}
return m, textinput.Blink
}
}
} else if m.state == stateDashboard {
// DASHBOARD STATE
keyStr := msg.String()
if keyStr == "ctrl+a" {
m.awaitingCommand = !m.awaitingCommand
if m.awaitingCommand {
m.lastStatusMsg = "Press '?' for help"
} else {
m.lastStatusMsg = ""
}
return m, nil
}
if m.awaitingCommand {
m.awaitingCommand = false
m.lastStatusMsg = ""
switch keyStr {
case "m":
m.lastStatusMsg = "Toggling Main Power..."
newState := !m.swStatus.Main
payload := ControlRequest{Load12vOn: &newState}
return m, sendControl(m.baseURL, m.token, payload)
case "u":
m.lastStatusMsg = "Toggling USB Power..."
newState := !m.swStatus.Usb
payload := ControlRequest{Load5vOn: &newState}
return m, sendControl(m.baseURL, m.token, payload)
case "r":
m.lastStatusMsg = "Sending Reset Signal..."
t := true
payload := ControlRequest{ResetTrigger: &t}
return m, sendControl(m.baseURL, m.token, payload)
case "p":
m.lastStatusMsg = "Sending Power Action..."
t := true
payload := ControlRequest{PowerTrigger: &t}
return m, sendControl(m.baseURL, m.token, payload)
case "a":
m.wsSend <- []byte{1}
return m, nil
case "o": // Open Settings
m.state = stateSettings
return m, fetchDeviceSettings(m.baseURL, m.token)
case "x": // Quit Program
return m, tea.Quit
case "q": // Close Command Window
m.lastStatusMsg = "Command mode closed."
return m, nil
default:
m.lastStatusMsg = "Command cancelled."
return m, nil
}
}
// Send to UART
var data []byte
switch msg.Type {
case tea.KeyRunes:
data = []byte(string(msg.Runes))
case tea.KeyEnter:
data = []byte{'\r'}
case tea.KeySpace:
data = []byte{' '}
case tea.KeyBackspace:
data = []byte{127}
case tea.KeyTab:
data = []byte{'\t'}
case tea.KeyEsc:
data = []byte{27}
case tea.KeyUp:
data = []byte("\x1b[A")
case tea.KeyDown:
data = []byte("\x1b[B")
case tea.KeyRight:
data = []byte("\x1b[C")
case tea.KeyLeft:
data = []byte("\x1b[D")
case tea.KeyHome:
data = []byte("\x1b[H")
case tea.KeyEnd:
data = []byte("\x1b[F")
case tea.KeyPgUp:
data = []byte("\x1b[5~")
case tea.KeyPgDown:
data = []byte("\x1b[6~")
case tea.KeyDelete:
data = []byte("\x1b[3~")
case tea.KeyInsert:
data = []byte("\x1b[2~")
default:
if len(keyStr) == 6 && strings.HasPrefix(keyStr, "ctrl+") {
c := keyStr[5]
if c >= 'a' && c <= 'z' {
data = []byte{byte(c - 'a' + 1)}
} else if c == '@' {
data = []byte{0}
} else if c == '[' {
data = []byte{27}
} else if c == '\\' {
data = []byte{28}
} else if c == ']' {
data = []byte{29}
} else if c == '^' {
data = []byte{30}
} else if c == '_' {
data = []byte{31}
}
} else if len(msg.Runes) > 0 {
data = []byte(string(msg.Runes))
}
}
if len(data) > 0 {
m.wsSend <- data
}
return m, nil
}
case tea.MouseMsg:
if m.state == stateDashboard {
var data []byte
switch msg.Type {
case tea.MouseWheelUp:
data = []byte("\x1b[A")
case tea.MouseWheelDown:
data = []byte("\x1b[B")
}
if len(data) > 0 {
m.wsSend <- data
}
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
headerHeight := 10
termW := msg.Width - 2
termH := msg.Height - headerHeight
if termH < 5 {
termH = 5
}
m.logViewport.Width = termW
m.logViewport.Height = termH
m.term.Resize(termW, termH)
case sessionMsg:
m.token = msg.token
m.state = stateDashboard
return m, tea.Batch(
connectWebSocket(m.baseURL, m.token, m.wsSend),
fetchControlStatus(m.baseURL, m.token),
)
case settingsMsg:
m.deviceSettings = msg.settings
return m, nil
case initStatusMsg:
m.swStatus.Main = msg.mainOn
m.swStatus.Usb = msg.usbOn
return m, nil
case errMsg:
m.err = msg
return m, nil
case wsMsg:
switch payload := msg.Payload.(type) {
case *pb.StatusMessage_SensorData:
m.sensorData = payload.SensorData
case *pb.StatusMessage_WifiStatus:
m.wifiStatus = payload.WifiStatus
case *pb.StatusMessage_SwStatus:
m.swStatus = payload.SwStatus
case *pb.StatusMessage_UartData:
m.term.Write(payload.UartData.Data)
m.logViewport.SetContent(m.renderVt10x())
}
}
return m, tea.Batch(cmds...)
}
func (m model) View() string {
if m.err != nil {
return fmt.Sprintf("\nError: %v\n\nPress q to quit", errorStyle.Render(m.err.Error()))
}
if m.state == stateLogin {
return m.loginView()
} else if m.state == stateSettings {
return m.settingsView()
}
return m.dashboardView()
}
func (m model) loginView() string {
var b strings.Builder
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(" ODROID Power Mate Login "))
b.WriteString("\n\n")
b.WriteString("Server Address:\n")
b.WriteString(m.serverInput.View())
b.WriteString("\n\n")
b.WriteString("Username:\n")
b.WriteString(m.usernameInput.View())
b.WriteString("\n\nPassword:\n")
b.WriteString(m.passwordInput.View())
b.WriteString("\n\n(Press Tab to switch, Enter to login, Ctrl+C to quit)")
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, b.String())
}
func (m model) settingsView() string {
var s strings.Builder
s.WriteString(titleStyle.Render(" Device Settings ") + "\n\n")
items := []struct {
label string
value string
}{
{"VIN Current Limit (A)", fmt.Sprintf("%.1f", m.deviceSettings.VinCurrentLimit)},
{"Main Current Limit (A)", fmt.Sprintf("%.1f", m.deviceSettings.MainCurrentLimit)},
{"USB Current Limit (A)", fmt.Sprintf("%.1f", m.deviceSettings.UsbCurrentLimit)},
{"UART Baud Rate", fmt.Sprintf("%d", m.deviceSettings.BaudRate)},
{"Sensor Period (ms)", fmt.Sprintf("%d", m.deviceSettings.SensorPeriod)},
{"[ Reboot Device ]", ""},
}
for i, item := range items {
label := item.label
value := item.value
if i == m.settingCursor {
s.WriteString(selectedItemStyle.Render(label))
if m.settingEditing {
s.WriteString(": " + m.settingInput.View())
} else {
s.WriteString(": " + value)
}
} else {
s.WriteString(settingItemStyle.Render(label + ": " + value))
}
s.WriteString("\n")
}
s.WriteString("\n(Enter to edit, Esc/q to back)")
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, s.String())
}
func (m model) dashboardView() string {
// Base Dashboard
header := titleStyle.Render(" ODROID Power Mate ")
if m.wifiStatus != nil {
ssid := m.wifiStatus.Ssid
if ssid == "" {
ssid = "Disconnected"
}
header += infoStyle.Render(fmt.Sprintf(" WiFi: %s (%s)", ssid, m.wifiStatus.IpAddress))
} else {
header += infoStyle.Render(" WiFi: --")
}
var statusText string
var statusStyle lipgloss.Style
if m.awaitingCommand {
statusText = " COMMAND MODE (Ctrl-A) >> Press: M(Main) U(USB) P(Power) R(Reset) O(Settings) A(Ctrl+A) Q(Close) X(Quit)"
statusStyle = commandModeStyle
} else {
statusText = " TERMINAL MODE >> Press Ctrl-A for Commands "
if m.lastStatusMsg != "" {
statusText += "| " + m.lastStatusMsg
}
statusStyle = statusBarStyle
}
bar := statusStyle.Width(m.width).Render(statusText)
metrics := ""
if m.sensorData != nil {
metrics = fmt.Sprintf(
"USB: %.2f V / %.2f A / %.2f W\nMAIN: %.2f V / %.2f A / %.2f W\nVIN: %.2f V / %.2f A / %.2f W\nUptime: %ds",
m.sensorData.Usb.Voltage, m.sensorData.Usb.Current, m.sensorData.Usb.Power,
m.sensorData.Main.Voltage, m.sensorData.Main.Current, m.sensorData.Main.Power,
m.sensorData.Vin.Voltage, m.sensorData.Vin.Current, m.sensorData.Vin.Power,
m.sensorData.UptimeMs/1000,
)
} else {
metrics = "Waiting for sensor data..."
}
metricsBox := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1).Width(m.width/2 - 2).Render(metrics)
switches := "States:\n"
mainState := "[OFF]"
if m.swStatus.Main {
mainState = "[ON ]"
}
usbState := "[OFF]"
if m.swStatus.Usb {
usbState = "[ON ]"
}
switches += fmt.Sprintf("Main: %s\n", mainState)
switches += fmt.Sprintf("USB : %s\n", usbState)
switchBox := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1).Width(m.width/2 - 2).Render(switches)
topSection := lipgloss.JoinHorizontal(lipgloss.Top, metricsBox, switchBox)
termView := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
Width(m.width - 2).
Height(m.logViewport.Height).
Render(m.logViewport.View())
baseView := lipgloss.JoinVertical(lipgloss.Left, header, topSection, bar, termView)
// --- Help Overlay ---
if m.awaitingCommand {
helpText := `
COMMAND MODE (Ctrl+A)
m : Toggle Main Power
u : Toggle USB Power
p : Power Trigger (Long Press)
r : Reset Trigger
o : Open Settings
a : Send 'Ctrl+A'
q : Close Command Window
x : Quit Program
`
helpWindow := helpBoxStyle.Render(helpText)
// Overlay help window on top of base view using lipgloss.Place
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, helpWindow, lipgloss.WithWhitespaceChars(" "), lipgloss.WithWhitespaceForeground(lipgloss.NoColor{}))
}
return baseView
}
func main() {
program = tea.NewProgram(initialModel(), tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := program.Run(); err != nil {
fmt.Printf("Error running program: %v\n", err)
os.Exit(1)
}
}

View File

@@ -0,0 +1,647 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.32.0
// 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"
)
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
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
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"`
}
func (x *SensorChannelData) Reset() {
*x = SensorChannelData{}
if protoimpl.UnsafeEnabled {
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 protoimpl.UnsafeEnabled && 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
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
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"`
}
func (x *SensorData) Reset() {
*x = SensorData{}
if protoimpl.UnsafeEnabled {
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 protoimpl.UnsafeEnabled && 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
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
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"`
}
func (x *WifiStatus) Reset() {
*x = WifiStatus{}
if protoimpl.UnsafeEnabled {
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 protoimpl.UnsafeEnabled && 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
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"`
}
func (x *UartData) Reset() {
*x = UartData{}
if protoimpl.UnsafeEnabled {
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 protoimpl.UnsafeEnabled && 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
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Main bool `protobuf:"varint,1,opt,name=main,proto3" json:"main,omitempty"`
Usb bool `protobuf:"varint,2,opt,name=usb,proto3" json:"usb,omitempty"`
}
func (x *LoadSwStatus) Reset() {
*x = LoadSwStatus{}
if protoimpl.UnsafeEnabled {
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 protoimpl.UnsafeEnabled && 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
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// Types that are assignable to Payload:
//
// *StatusMessage_SensorData
// *StatusMessage_WifiStatus
// *StatusMessage_SwStatus
// *StatusMessage_UartData
Payload isStatusMessage_Payload `protobuf_oneof:"payload"`
}
func (x *StatusMessage) Reset() {
*x = StatusMessage{}
if protoimpl.UnsafeEnabled {
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 protoimpl.UnsafeEnabled && 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 (m *StatusMessage) GetPayload() isStatusMessage_Payload {
if m != nil {
return m.Payload
}
return nil
}
func (x *StatusMessage) GetSensorData() *SensorData {
if x, ok := x.GetPayload().(*StatusMessage_SensorData); ok {
return x.SensorData
}
return nil
}
func (x *StatusMessage) GetWifiStatus() *WifiStatus {
if x, ok := x.GetPayload().(*StatusMessage_WifiStatus); ok {
return x.WifiStatus
}
return nil
}
func (x *StatusMessage) GetSwStatus() *LoadSwStatus {
if x, ok := x.GetPayload().(*StatusMessage_SwStatus); ok {
return x.SwStatus
}
return nil
}
func (x *StatusMessage) GetUartData() *UartData {
if x, ok := x.GetPayload().(*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
var file_status_proto_rawDesc = []byte{
0x0a, 0x0c, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x06,
0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x5d, 0x0a, 0x11, 0x53, 0x65, 0x6e, 0x73, 0x6f, 0x72,
0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x44, 0x61, 0x74, 0x61, 0x12, 0x18, 0x0a, 0x07, 0x76,
0x6f, 0x6c, 0x74, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x02, 0x52, 0x07, 0x76, 0x6f,
0x6c, 0x74, 0x61, 0x67, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74,
0x18, 0x02, 0x20, 0x01, 0x28, 0x02, 0x52, 0x07, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x12,
0x14, 0x0a, 0x05, 0x70, 0x6f, 0x77, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, 0x52, 0x05,
0x70, 0x6f, 0x77, 0x65, 0x72, 0x22, 0xd5, 0x01, 0x0a, 0x0a, 0x53, 0x65, 0x6e, 0x73, 0x6f, 0x72,
0x44, 0x61, 0x74, 0x61, 0x12, 0x2b, 0x0a, 0x03, 0x75, 0x73, 0x62, 0x18, 0x01, 0x20, 0x01, 0x28,
0x0b, 0x32, 0x19, 0x2e, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x53, 0x65, 0x6e, 0x73, 0x6f,
0x72, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x44, 0x61, 0x74, 0x61, 0x52, 0x03, 0x75, 0x73,
0x62, 0x12, 0x2d, 0x0a, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x19, 0x2e, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x53, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x43,
0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x44, 0x61, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x61, 0x69, 0x6e,
0x12, 0x2b, 0x0a, 0x03, 0x76, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e,
0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x53, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x43, 0x68, 0x61,
0x6e, 0x6e, 0x65, 0x6c, 0x44, 0x61, 0x74, 0x61, 0x52, 0x03, 0x76, 0x69, 0x6e, 0x12, 0x21, 0x0a,
0x0c, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20,
0x01, 0x28, 0x04, 0x52, 0x0b, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x4d, 0x73,
0x12, 0x1b, 0x0a, 0x09, 0x75, 0x70, 0x74, 0x69, 0x6d, 0x65, 0x5f, 0x6d, 0x73, 0x18, 0x05, 0x20,
0x01, 0x28, 0x04, 0x52, 0x08, 0x75, 0x70, 0x74, 0x69, 0x6d, 0x65, 0x4d, 0x73, 0x22, 0x71, 0x0a,
0x0a, 0x57, 0x69, 0x66, 0x69, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x63,
0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09,
0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x73, 0x69,
0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x73, 0x73, 0x69, 0x64, 0x12, 0x12, 0x0a,
0x04, 0x72, 0x73, 0x73, 0x69, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x72, 0x73, 0x73,
0x69, 0x12, 0x1d, 0x0a, 0x0a, 0x69, 0x70, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18,
0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x69, 0x70, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
0x22, 0x1e, 0x0a, 0x08, 0x55, 0x61, 0x72, 0x74, 0x44, 0x61, 0x74, 0x61, 0x12, 0x12, 0x0a, 0x04,
0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61,
0x22, 0x34, 0x0a, 0x0c, 0x4c, 0x6f, 0x61, 0x64, 0x53, 0x77, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
0x12, 0x12, 0x0a, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04,
0x6d, 0x61, 0x69, 0x6e, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x73, 0x62, 0x18, 0x02, 0x20, 0x01, 0x28,
0x08, 0x52, 0x03, 0x75, 0x73, 0x62, 0x22, 0xee, 0x01, 0x0a, 0x0d, 0x53, 0x74, 0x61, 0x74, 0x75,
0x73, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x35, 0x0a, 0x0b, 0x73, 0x65, 0x6e, 0x73,
0x6f, 0x72, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e,
0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x53, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x44, 0x61, 0x74,
0x61, 0x48, 0x00, 0x52, 0x0a, 0x73, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x44, 0x61, 0x74, 0x61, 0x12,
0x35, 0x0a, 0x0b, 0x77, 0x69, 0x66, 0x69, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x57, 0x69,
0x66, 0x69, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x48, 0x00, 0x52, 0x0a, 0x77, 0x69, 0x66, 0x69,
0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x77, 0x5f, 0x73, 0x74, 0x61,
0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x73, 0x74, 0x61, 0x74,
0x75, 0x73, 0x2e, 0x4c, 0x6f, 0x61, 0x64, 0x53, 0x77, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x48,
0x00, 0x52, 0x08, 0x73, 0x77, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2f, 0x0a, 0x09, 0x75,
0x61, 0x72, 0x74, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10,
0x2e, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x55, 0x61, 0x72, 0x74, 0x44, 0x61, 0x74, 0x61,
0x48, 0x00, 0x52, 0x08, 0x75, 0x61, 0x72, 0x74, 0x44, 0x61, 0x74, 0x61, 0x42, 0x09, 0x0a, 0x07,
0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x42, 0x06, 0x5a, 0x04, 0x2e, 0x2f, 0x70, 0x62, 0x62,
0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_status_proto_rawDescOnce sync.Once
file_status_proto_rawDescData = file_status_proto_rawDesc
)
func file_status_proto_rawDescGZIP() []byte {
file_status_proto_rawDescOnce.Do(func() {
file_status_proto_rawDescData = protoimpl.X.CompressGZIP(file_status_proto_rawDescData)
})
return file_status_proto_rawDescData
}
var file_status_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
var file_status_proto_goTypes = []interface{}{
(*SensorChannelData)(nil), // 0: status.SensorChannelData
(*SensorData)(nil), // 1: status.SensorData
(*WifiStatus)(nil), // 2: status.WifiStatus
(*UartData)(nil), // 3: status.UartData
(*LoadSwStatus)(nil), // 4: status.LoadSwStatus
(*StatusMessage)(nil), // 5: status.StatusMessage
}
var file_status_proto_depIdxs = []int32{
0, // 0: status.SensorData.usb:type_name -> status.SensorChannelData
0, // 1: status.SensorData.main:type_name -> status.SensorChannelData
0, // 2: status.SensorData.vin:type_name -> status.SensorChannelData
1, // 3: status.StatusMessage.sensor_data:type_name -> status.SensorData
2, // 4: status.StatusMessage.wifi_status:type_name -> status.WifiStatus
4, // 5: status.StatusMessage.sw_status:type_name -> status.LoadSwStatus
3, // 6: status.StatusMessage.uart_data:type_name -> status.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
}
if !protoimpl.UnsafeEnabled {
file_status_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*SensorChannelData); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_status_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*SensorData); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_status_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*WifiStatus); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_status_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*UartData); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_status_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*LoadSwStatus); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_status_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*StatusMessage); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
file_status_proto_msgTypes[5].OneofWrappers = []interface{}{
(*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: 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_rawDesc = nil
file_status_proto_goTypes = nil
file_status_proto_depIdxs = nil
}

View File

@@ -0,0 +1,49 @@
syntax = "proto3";
package status;
option go_package = "./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

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

View File

@@ -1,32 +1,58 @@
import argparse import argparse
import matplotlib
matplotlib.use('Agg')
import matplotlib.dates as mdates import matplotlib.dates as mdates
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import pandas as pd import pandas as pd
from dateutil.tz import gettz from 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. Reads power data from a CSV file and generates a plot image.
Args: Args:
csv_path (str): The path to the input CSV file. csv_path (str): The path to the input CSV file.
output_path (str): The path to save the output plot image. output_path (str): The path to save the output plot image.
plot_types (list): A list of strings indicating which plots to generate plot_types (list): A list of strings indicating which plots to generate.
(e.g., ['power', 'voltage', 'current']). sources (list): A list of strings indicating which power sources to plot.
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.
(e.g., ['vin', 'main', 'usb']). 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: try:
# Read the CSV file into a pandas DataFrame # Read the CSV file into a pandas DataFrame
df = pd.read_csv(csv_path, parse_dates=['timestamp']) df = pd.read_csv(csv_path, parse_dates=['timestamp'])
print(f"Successfully loaded {len(df)} records from '{csv_path}'") print(f"Successfully loaded {len(df)} records from '{csv_path}'")
# --- Timezone Conversion --- if df.empty:
local_tz = gettz() print("CSV file is empty. Exiting.")
df['timestamp'] = df['timestamp'].dt.tz_convert(local_tz) return
print(f"Timestamp converted to local timezone: {local_tz}")
# --- 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: except FileNotFoundError:
print(f"Error: The file '{csv_path}' was not found.") 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 return
# --- Calculate Average Interval --- # --- Calculate Average Interval ---
avg_interval_ms = 0 avg_interval_s = 0
if len(df) > 1: if len(df) > 1:
avg_interval = df['timestamp'].diff().mean() 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 --- # --- Calculate Average Voltages ---
avg_voltages = {} avg_voltages = {}
@@ -50,15 +83,20 @@ def plot_power_data(csv_path, output_path, plot_types, sources):
# --- Plotting Configuration --- # --- Plotting Configuration ---
scale_config = { scale_config = {
'power': {'steps': [5, 20, 50, 160]}, 'power': {'steps': [5, 7, 10, 20, 50, 160]},
'voltage': {'steps': [5, 10, 15, 25]}, 'voltage': {'steps': [5, 7, 10, 15, 20, 25]},
'current': {'steps': [1, 2.5, 5, 10]} 'current': {'steps': [1, 2.5, 5, 7.5, 10]}
} }
plot_configs = { plot_configs = {
'power': {'title': 'Power Consumption', 'ylabel': 'Power (W)', 'cols': [f'{s}_power' for s in sources]}, '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]}, '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]} '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] channel_labels = [s.upper() for s in sources]
color_map = {'vin': 'red', 'main': 'green', 'usb': 'blue'} 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 max_data_value = 0
for j, col_name in enumerate(config['cols']): for j, col_name in enumerate(config['cols']):
if col_name in df.columns: 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() max_col_value = df[col_name].max()
if max_col_value > max_data_value: if max_col_value > max_data_value:
max_data_value = max_col_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: else:
print(f"Warning: Column '{col_name}' not found in CSV. Skipping.") print(f"Warning: Column '{col_name}' not found in CSV. Skipping.")
# --- Dynamic Y-axis Scaling --- # --- Dynamic Y-axis Scaling ---
ax.set_ylim(bottom=0) 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'] steps = scale_config[plot_type]['steps']
new_max = next((step for step in steps if step >= max_data_value), steps[-1]) new_max = next((step for step in steps if step >= max_data_value), steps[-1])
ax.set_ylim(top=new_max) 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.set_ylabel(config['ylabel'])
ax.legend() ax.legend()
# --- Grid and Tick Configuration --- # --- Y-Grid and Tick Configuration ---
y_min, y_max = ax.get_ylim() y_min, y_max = ax.get_ylim()
# Keep the dynamic major_interval logic for tick LABELS if y_max <= 0:
if plot_type == 'current' and y_max <= 2.5: major_interval = 1.0 # Default for very small or zero range
major_interval = 0.5 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: 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: elif y_max <= 25:
major_interval = 5 major_interval = 5.0 # Already a multiple of 5
else: else: # y_max > 25
major_interval = y_max / 5.0 # 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_major_locator(MultipleLocator(major_interval))
ax.yaxis.set_minor_locator(MultipleLocator(1)) 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(False, which='major')
ax.yaxis.grid(True, which='minor', linestyle='--', linewidth=0.6, zorder=0) 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): for y_val in range(int(y_min), int(y_max) + 1):
if y_val == 0: continue if y_val == 0: continue
if y_val % 10 == 0: 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: elif y_val % 5 == 0:
ax.axhline(y=y_val, color='midnightblue', linestyle='--', linewidth=1.2, zorder=1) 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) 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] last_ax = axes[-1]
if not df.empty: 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) plt.xticks(rotation=45)
# --- Add a main title and subtitle --- # --- Add a main title and subtitle ---
start_time = df['timestamp'].iloc[0].strftime('%Y-%m-%d %H:%M:%S') if relative_time:
end_time = df['timestamp'].iloc[-1].strftime('%H:%M:%S') main_title = 'PowerMate Log'
main_title = f'PowerMate Log ({start_time} to {end_time})' 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 = [] subtitle_parts = []
if avg_interval_ms > 0: if avg_interval_ms > 0:
subtitle_parts.append(f'Avg. Interval: {avg_interval_ms:.2f} ms') 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()] voltage_strings = [f'{source.upper()} Avg: {avg_v:.2f} V' for source, avg_v in avg_voltages.items()]
if voltage_strings: if voltage_strings:
subtitle_parts.extend(voltage_strings) subtitle_parts.extend(voltage_strings)
subtitle = ' | '.join(subtitle_parts) subtitle = ' | '.join(subtitle_parts)
full_title = main_title full_title = main_title
if subtitle: if subtitle:
full_title += f'\n{subtitle}' full_title += f'\n{subtitle}'
fig.suptitle(full_title, fontsize=14) fig.suptitle(full_title, fontsize=14)
# Adjust layout to make space for the subtitle
plt.tight_layout(rect=[0, 0, 1, 0.98]) plt.tight_layout(rect=[0, 0, 1, 0.98])
# --- Save the plot to a file --- # --- Save the plot to a file ---
@@ -192,9 +295,44 @@ def main():
help="Power sources to plot. Choose from 'vin', 'main', 'usb'. " help="Power sources to plot. Choose from 'vin', 'main', 'usb'. "
"Default is to plot all three." "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() 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__": if __name__ == "__main__":

View File

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

View File

@@ -54,9 +54,9 @@ ina3221_t ina3221 = {
.ch1 = true, // channel 1 enable .ch1 = true, // channel 1 enable
.ch2 = true, // channel 2 enable .ch2 = true, // channel 2 enable
.ch3 = true, // channel 3 enable .ch3 = true, // channel 3 enable
.avg = INA3221_AVG_64, // 64 samples average .avg = INA3221_AVG_16, // 16 samples average
.vbus = INA3221_CT_2116, // 2ms by channel (bus) .vbus = INA3221_CT_140, // 140us by channel (bus)
.vsht = INA3221_CT_2116, // 2ms by channel (shunt) .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) 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; return ESP_ERR_INVALID_ARG;
} }

View File

@@ -379,7 +379,7 @@
</div> </div>
<div class="mb-3 p-3 border rounded"> <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> <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"> <div class="d-flex justify-content-end mt-2">
<button type="button" class="btn btn-primary btn-sm" id="period-apply-button">Apply</button> <button type="button" class="btn btn-primary btn-sm" id="period-apply-button">Apply</button>
</div> </div>