console-frontend: add settings management UI and device API integration
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -57,6 +58,17 @@ var (
|
|||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
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 ---
|
// --- Messages ---
|
||||||
@@ -69,10 +81,25 @@ type initStatusMsg struct {
|
|||||||
usbOn bool
|
usbOn bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type settingsMsg struct {
|
||||||
|
settings DeviceSettings
|
||||||
|
}
|
||||||
|
|
||||||
type errMsg error
|
type errMsg error
|
||||||
|
|
||||||
type wsMsg *pb.StatusMessage
|
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 ---
|
// --- API Client ---
|
||||||
type LoginRequest struct {
|
type LoginRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -146,6 +173,62 @@ func fetchControlStatus(baseURL, token string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func sendControl(baseURL, token string, payload ControlRequest) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
baseURL = strings.TrimRight(baseURL, "/")
|
baseURL = strings.TrimRight(baseURL, "/")
|
||||||
@@ -187,7 +270,6 @@ func connectWebSocket(baseURL, token string, sendChan <-chan []byte) tea.Cmd {
|
|||||||
return errMsg(err)
|
return errMsg(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Writer
|
|
||||||
go func() {
|
go func() {
|
||||||
for data := range sendChan {
|
for data := range sendChan {
|
||||||
err := c.WriteMessage(websocket.BinaryMessage, data)
|
err := c.WriteMessage(websocket.BinaryMessage, data)
|
||||||
@@ -198,7 +280,6 @@ func connectWebSocket(baseURL, token string, sendChan <-chan []byte) tea.Cmd {
|
|||||||
c.Close()
|
c.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Reader
|
|
||||||
go func() {
|
go func() {
|
||||||
defer c.Close()
|
defer c.Close()
|
||||||
for {
|
for {
|
||||||
@@ -224,6 +305,17 @@ type state int
|
|||||||
const (
|
const (
|
||||||
stateLogin state = iota
|
stateLogin state = iota
|
||||||
stateDashboard
|
stateDashboard
|
||||||
|
stateSettings
|
||||||
|
)
|
||||||
|
|
||||||
|
// Setting Item Types
|
||||||
|
const (
|
||||||
|
SetVinLimit = iota
|
||||||
|
SetMainLimit
|
||||||
|
SetUsbLimit
|
||||||
|
SetBaudRate
|
||||||
|
SetPeriod
|
||||||
|
SetReboot
|
||||||
)
|
)
|
||||||
|
|
||||||
type model struct {
|
type model struct {
|
||||||
@@ -236,21 +328,26 @@ type model struct {
|
|||||||
|
|
||||||
wsSend chan []byte
|
wsSend chan []byte
|
||||||
|
|
||||||
|
// Login
|
||||||
serverInput textinput.Model
|
serverInput textinput.Model
|
||||||
usernameInput textinput.Model
|
usernameInput textinput.Model
|
||||||
passwordInput textinput.Model
|
passwordInput textinput.Model
|
||||||
focusIndex int
|
focusIndex int
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
sensorData *pb.SensorData
|
sensorData *pb.SensorData
|
||||||
wifiStatus *pb.WifiStatus
|
wifiStatus *pb.WifiStatus
|
||||||
swStatus *pb.LoadSwStatus
|
swStatus *pb.LoadSwStatus
|
||||||
|
|
||||||
// Terminal Emulator (vt10x)
|
|
||||||
term vt10x.Terminal
|
term vt10x.Terminal
|
||||||
logViewport viewport.Model
|
logViewport viewport.Model
|
||||||
|
|
||||||
awaitingCommand bool
|
awaitingCommand bool
|
||||||
lastStatusMsg string
|
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 {
|
func initialModel() model {
|
||||||
@@ -266,13 +363,16 @@ func initialModel() model {
|
|||||||
p.Placeholder = "Password"
|
p.Placeholder = "Password"
|
||||||
p.EchoMode = textinput.EchoPassword
|
p.EchoMode = textinput.EchoPassword
|
||||||
|
|
||||||
// 기본 80x24 초기화
|
|
||||||
vp := viewport.New(80, 24)
|
vp := viewport.New(80, 24)
|
||||||
vp.SetContent("Waiting for data...")
|
vp.SetContent("Waiting for data...")
|
||||||
|
|
||||||
|
// vt10x 초기화 (Writer는 필요 없으므로 Discard)
|
||||||
term := vt10x.New(vt10x.WithWriter(io.Discard))
|
term := vt10x.New(vt10x.WithWriter(io.Discard))
|
||||||
term.Resize(80, 24)
|
term.Resize(80, 24)
|
||||||
|
|
||||||
|
si := textinput.New()
|
||||||
|
si.CharLimit = 10
|
||||||
|
|
||||||
return model{
|
return model{
|
||||||
state: stateLogin,
|
state: stateLogin,
|
||||||
serverInput: s,
|
serverInput: s,
|
||||||
@@ -282,6 +382,7 @@ func initialModel() model {
|
|||||||
term: term,
|
term: term,
|
||||||
swStatus: &pb.LoadSwStatus{Main: false, Usb: false},
|
swStatus: &pb.LoadSwStatus{Main: false, Usb: false},
|
||||||
wsSend: make(chan []byte, 100),
|
wsSend: make(chan []byte, 100),
|
||||||
|
settingInput: si,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,21 +390,16 @@ func (m model) Init() tea.Cmd {
|
|||||||
return textinput.Blink
|
return textinput.Blink
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderVt10x: vt10x 상태를 ANSI 문자열로 직접 변환 (최적화됨)
|
|
||||||
func (m model) renderVt10x() string {
|
func (m model) renderVt10x() string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
// 버퍼를 미리 할당 (가로 * 세로 * 문자당 평균 바이트 수 추정)
|
|
||||||
rows := m.logViewport.Height
|
rows := m.logViewport.Height
|
||||||
cols := m.logViewport.Width
|
cols := m.logViewport.Width
|
||||||
sb.Grow(rows * cols * 4)
|
sb.Grow(rows * cols * 4)
|
||||||
|
|
||||||
cursor := m.term.Cursor()
|
cursor := m.term.Cursor()
|
||||||
|
|
||||||
// 현재 상태 추적용 변수 (중복 ANSI 코드 제거)
|
|
||||||
curFG := vt10x.DefaultFG
|
curFG := vt10x.DefaultFG
|
||||||
curBG := vt10x.DefaultBG
|
curBG := vt10x.DefaultBG
|
||||||
|
|
||||||
// 시작 시 스타일 초기화
|
|
||||||
sb.WriteString("\x1b[0m")
|
sb.WriteString("\x1b[0m")
|
||||||
|
|
||||||
for y := 0; y < rows; y++ {
|
for y := 0; y < rows; y++ {
|
||||||
@@ -313,39 +409,29 @@ func (m model) renderVt10x() string {
|
|||||||
fg := cell.FG
|
fg := cell.FG
|
||||||
bg := cell.BG
|
bg := cell.BG
|
||||||
char := cell.Char
|
char := cell.Char
|
||||||
|
|
||||||
// 커서 위치 판별
|
|
||||||
isCursor := (x == cursor.X && y == cursor.Y)
|
isCursor := (x == cursor.X && y == cursor.Y)
|
||||||
|
|
||||||
// 상태가 변경되었을 때만 ANSI 코드 출력 (커서 위치 포함)
|
|
||||||
if fg != curFG || bg != curBG || isCursor {
|
if fg != curFG || bg != curBG || isCursor {
|
||||||
sb.WriteString("\x1b[0m") // Reset
|
sb.WriteString("\x1b[0m")
|
||||||
|
|
||||||
// 커서 위치면 반전(Reverse) 적용
|
|
||||||
if isCursor {
|
if isCursor {
|
||||||
sb.WriteString("\x1b[7m")
|
sb.WriteString("\x1b[7m")
|
||||||
}
|
}
|
||||||
|
|
||||||
if fg != vt10x.DefaultFG {
|
if fg != vt10x.DefaultFG {
|
||||||
fmt.Fprintf(&sb, "\x1b[38;5;%dm", fg)
|
fmt.Fprintf(&sb, "\x1b[38;5;%dm", fg)
|
||||||
}
|
}
|
||||||
if bg != vt10x.DefaultBG {
|
if bg != vt10x.DefaultBG {
|
||||||
fmt.Fprintf(&sb, "\x1b[48;5;%dm", bg)
|
fmt.Fprintf(&sb, "\x1b[48;5;%dm", bg)
|
||||||
}
|
}
|
||||||
|
|
||||||
curFG = fg
|
curFG = fg
|
||||||
curBG = bg
|
curBG = bg
|
||||||
|
|
||||||
// 커서가 지나간 후 다음 글자에서 스타일이 리셋되어야 하므로 상태 강제 변경
|
|
||||||
if isCursor {
|
if isCursor {
|
||||||
curFG = vt10x.Color(65535)
|
curFG = vt10x.Color(65535)
|
||||||
curBG = vt10x.Color(65535)
|
curBG = vt10x.Color(65535)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteRune(char)
|
sb.WriteRune(char)
|
||||||
}
|
}
|
||||||
// 줄바꿈 전 스타일 초기화
|
|
||||||
if y < rows-1 {
|
if y < rows-1 {
|
||||||
sb.WriteString("\x1b[0m\n")
|
sb.WriteString("\x1b[0m\n")
|
||||||
curFG = vt10x.DefaultFG
|
curFG = vt10x.DefaultFG
|
||||||
@@ -360,23 +446,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
// --- Mouse Scroll Handling ---
|
|
||||||
case tea.MouseMsg:
|
|
||||||
if m.state == stateDashboard {
|
|
||||||
var data []byte
|
|
||||||
switch msg.Type {
|
|
||||||
case tea.MouseWheelUp:
|
|
||||||
data = []byte("\x1b[A") // Arrow Up
|
|
||||||
case tea.MouseWheelDown:
|
|
||||||
data = []byte("\x1b[B") // Arrow Down
|
|
||||||
}
|
|
||||||
if len(data) > 0 {
|
|
||||||
m.wsSend <- data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
// LOGIN STATE
|
|
||||||
if m.state == stateLogin {
|
if m.state == stateLogin {
|
||||||
if msg.Type == tea.KeyCtrlC {
|
if msg.Type == tea.KeyCtrlC {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -417,24 +487,92 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, tea.Batch(cmds...)
|
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
|
||||||
|
|
||||||
// DASHBOARD STATE
|
switch m.settingCursor {
|
||||||
if m.state == stateDashboard {
|
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()
|
keyStr := msg.String()
|
||||||
|
|
||||||
// 1. Ctrl+A Command Mode Trigger
|
|
||||||
if keyStr == "ctrl+a" {
|
if keyStr == "ctrl+a" {
|
||||||
m.awaitingCommand = !m.awaitingCommand
|
m.awaitingCommand = !m.awaitingCommand
|
||||||
if m.awaitingCommand {
|
if m.awaitingCommand {
|
||||||
m.lastStatusMsg = "Command Mode: Press key (m:Main, u:USB, p:Power, r:Reset, q:Quit, a:Ctrl+A)"
|
m.lastStatusMsg = "Press '?' for help"
|
||||||
} else {
|
} else {
|
||||||
m.lastStatusMsg = ""
|
m.lastStatusMsg = ""
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Handle Command Mode Local Keys
|
|
||||||
if m.awaitingCommand {
|
if m.awaitingCommand {
|
||||||
m.awaitingCommand = false
|
m.awaitingCommand = false
|
||||||
m.lastStatusMsg = ""
|
m.lastStatusMsg = ""
|
||||||
@@ -459,20 +597,25 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
t := true
|
t := true
|
||||||
payload := ControlRequest{PowerTrigger: &t}
|
payload := ControlRequest{PowerTrigger: &t}
|
||||||
return m, sendControl(m.baseURL, m.token, payload)
|
return m, sendControl(m.baseURL, m.token, payload)
|
||||||
case "a": // 1. Send literal Ctrl+A
|
case "a":
|
||||||
m.wsSend <- []byte{1} // 0x01
|
m.wsSend <- []byte{1}
|
||||||
return m, nil
|
return m, nil
|
||||||
case "q":
|
case "o": // Open Settings
|
||||||
|
m.state = stateSettings
|
||||||
|
return m, fetchDeviceSettings(m.baseURL, m.token)
|
||||||
|
case "x": // Quit Program
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
case "q": // Close Command Window
|
||||||
|
m.lastStatusMsg = "Command mode closed."
|
||||||
|
return m, nil
|
||||||
default:
|
default:
|
||||||
m.lastStatusMsg = "Command cancelled."
|
m.lastStatusMsg = "Command cancelled."
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Send ALL other inputs to Device
|
// Send to UART
|
||||||
var data []byte
|
var data []byte
|
||||||
|
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case tea.KeyRunes:
|
case tea.KeyRunes:
|
||||||
data = []byte(string(msg.Runes))
|
data = []byte(string(msg.Runes))
|
||||||
@@ -528,25 +671,38 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
data = []byte(string(msg.Runes))
|
data = []byte(string(msg.Runes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(data) > 0 {
|
if len(data) > 0 {
|
||||||
m.wsSend <- data
|
m.wsSend <- data
|
||||||
}
|
}
|
||||||
return m, nil
|
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:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
headerHeight := 10
|
headerHeight := 10
|
||||||
termW := msg.Width - 2
|
termW := msg.Width - 2
|
||||||
termH := msg.Height - headerHeight
|
termH := msg.Height - headerHeight
|
||||||
if termH < 5 { termH = 5 }
|
if termH < 5 {
|
||||||
|
termH = 5
|
||||||
|
}
|
||||||
|
|
||||||
m.logViewport.Width = termW
|
m.logViewport.Width = termW
|
||||||
m.logViewport.Height = termH
|
m.logViewport.Height = termH
|
||||||
|
|
||||||
// 터미널 에뮬레이터 리사이즈
|
|
||||||
m.term.Resize(termW, termH)
|
m.term.Resize(termW, termH)
|
||||||
|
|
||||||
case sessionMsg:
|
case sessionMsg:
|
||||||
@@ -557,6 +713,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
fetchControlStatus(m.baseURL, m.token),
|
fetchControlStatus(m.baseURL, m.token),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
case settingsMsg:
|
||||||
|
m.deviceSettings = msg.settings
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case initStatusMsg:
|
case initStatusMsg:
|
||||||
m.swStatus.Main = msg.mainOn
|
m.swStatus.Main = msg.mainOn
|
||||||
m.swStatus.Usb = msg.usbOn
|
m.swStatus.Usb = msg.usbOn
|
||||||
@@ -575,8 +735,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case *pb.StatusMessage_SwStatus:
|
case *pb.StatusMessage_SwStatus:
|
||||||
m.swStatus = payload.SwStatus
|
m.swStatus = payload.SwStatus
|
||||||
case *pb.StatusMessage_UartData:
|
case *pb.StatusMessage_UartData:
|
||||||
incoming := payload.UartData.Data
|
m.term.Write(payload.UartData.Data)
|
||||||
m.term.Write(incoming)
|
|
||||||
m.logViewport.SetContent(m.renderVt10x())
|
m.logViewport.SetContent(m.renderVt10x())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -591,6 +750,8 @@ func (m model) View() string {
|
|||||||
|
|
||||||
if m.state == stateLogin {
|
if m.state == stateLogin {
|
||||||
return m.loginView()
|
return m.loginView()
|
||||||
|
} else if m.state == stateSettings {
|
||||||
|
return m.settingsView()
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.dashboardView()
|
return m.dashboardView()
|
||||||
@@ -614,7 +775,45 @@ func (m model) loginView() string {
|
|||||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, b.String())
|
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 {
|
func (m model) dashboardView() string {
|
||||||
|
// Base Dashboard
|
||||||
header := titleStyle.Render(" ODROID Power Mate ")
|
header := titleStyle.Render(" ODROID Power Mate ")
|
||||||
if m.wifiStatus != nil {
|
if m.wifiStatus != nil {
|
||||||
ssid := m.wifiStatus.Ssid
|
ssid := m.wifiStatus.Ssid
|
||||||
@@ -629,7 +828,7 @@ func (m model) dashboardView() string {
|
|||||||
var statusText string
|
var statusText string
|
||||||
var statusStyle lipgloss.Style
|
var statusStyle lipgloss.Style
|
||||||
if m.awaitingCommand {
|
if m.awaitingCommand {
|
||||||
statusText = " COMMAND MODE (Ctrl-A) >> Press: M(Main) U(USB) P(Power) R(Reset) Q(Quit) "
|
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
|
statusStyle = commandModeStyle
|
||||||
} else {
|
} else {
|
||||||
statusText = " TERMINAL MODE >> Press Ctrl-A for Commands "
|
statusText = " TERMINAL MODE >> Press Ctrl-A for Commands "
|
||||||
@@ -676,7 +875,29 @@ func (m model) dashboardView() string {
|
|||||||
Height(m.logViewport.Height).
|
Height(m.logViewport.Height).
|
||||||
Render(m.logViewport.View())
|
Render(m.logViewport.View())
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, header, topSection, bar, termView)
|
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() {
|
func main() {
|
||||||
|
|||||||
Reference in New Issue
Block a user