console-frontend: add settings management UI and device API integration

Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
This commit is contained in:
2025-12-12 18:40:24 +09:00
parent edf6cf40cb
commit cb5b9c7d5e

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
@@ -57,6 +58,17 @@ var (
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 ---
@@ -69,10 +81,25 @@ type initStatusMsg struct {
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"`
@@ -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 {
return func() tea.Msg {
baseURL = strings.TrimRight(baseURL, "/")
@@ -187,7 +270,6 @@ func connectWebSocket(baseURL, token string, sendChan <-chan []byte) tea.Cmd {
return errMsg(err)
}
// Writer
go func() {
for data := range sendChan {
err := c.WriteMessage(websocket.BinaryMessage, data)
@@ -198,7 +280,6 @@ func connectWebSocket(baseURL, token string, sendChan <-chan []byte) tea.Cmd {
c.Close()
}()
// Reader
go func() {
defer c.Close()
for {
@@ -224,6 +305,17 @@ type state int
const (
stateLogin state = iota
stateDashboard
stateSettings
)
// Setting Item Types
const (
SetVinLimit = iota
SetMainLimit
SetUsbLimit
SetBaudRate
SetPeriod
SetReboot
)
type model struct {
@@ -236,21 +328,26 @@ type model struct {
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
// Terminal Emulator (vt10x)
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 {
@@ -266,13 +363,16 @@ func initialModel() model {
p.Placeholder = "Password"
p.EchoMode = textinput.EchoPassword
// 기본 80x24 초기화
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,
@@ -282,6 +382,7 @@ func initialModel() model {
term: term,
swStatus: &pb.LoadSwStatus{Main: false, Usb: false},
wsSend: make(chan []byte, 100),
settingInput: si,
}
}
@@ -289,21 +390,16 @@ func (m model) Init() tea.Cmd {
return textinput.Blink
}
// renderVt10x: vt10x 상태를 ANSI 문자열로 직접 변환 (최적화됨)
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()
// 현재 상태 추적용 변수 (중복 ANSI 코드 제거)
curFG := vt10x.DefaultFG
curBG := vt10x.DefaultBG
// 시작 시 스타일 초기화
sb.WriteString("\x1b[0m")
for y := 0; y < rows; y++ {
@@ -313,39 +409,29 @@ func (m model) renderVt10x() string {
fg := cell.FG
bg := cell.BG
char := cell.Char
// 커서 위치 판별
isCursor := (x == cursor.X && y == cursor.Y)
// 상태가 변경되었을 때만 ANSI 코드 출력 (커서 위치 포함)
if fg != curFG || bg != curBG || isCursor {
sb.WriteString("\x1b[0m") // Reset
// 커서 위치면 반전(Reverse) 적용
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
@@ -360,23 +446,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
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:
// LOGIN STATE
if m.state == stateLogin {
if msg.Type == tea.KeyCtrlC {
return m, tea.Quit
@@ -417,24 +487,92 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.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
if m.state == stateDashboard {
keyStr := msg.String()
// 1. Ctrl+A Command Mode Trigger
if keyStr == "ctrl+a" {
m.awaitingCommand = !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 {
m.lastStatusMsg = ""
}
return m, nil
}
// 2. Handle Command Mode Local Keys
if m.awaitingCommand {
m.awaitingCommand = false
m.lastStatusMsg = ""
@@ -459,20 +597,25 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
t := true
payload := ControlRequest{PowerTrigger: &t}
return m, sendControl(m.baseURL, m.token, payload)
case "a": // 1. Send literal Ctrl+A
m.wsSend <- []byte{1} // 0x01
case "a":
m.wsSend <- []byte{1}
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
case "q": // Close Command Window
m.lastStatusMsg = "Command mode closed."
return m, nil
default:
m.lastStatusMsg = "Command cancelled."
return m, nil
}
}
// 3. Send ALL other inputs to Device
// Send to UART
var data []byte
switch msg.Type {
case tea.KeyRunes:
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))
}
}
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 }
if termH < 5 {
termH = 5
}
m.logViewport.Width = termW
m.logViewport.Height = termH
// 터미널 에뮬레이터 리사이즈
m.term.Resize(termW, termH)
case sessionMsg:
@@ -557,6 +713,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
@@ -575,8 +735,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case *pb.StatusMessage_SwStatus:
m.swStatus = payload.SwStatus
case *pb.StatusMessage_UartData:
incoming := payload.UartData.Data
m.term.Write(incoming)
m.term.Write(payload.UartData.Data)
m.logViewport.SetContent(m.renderVt10x())
}
}
@@ -591,6 +750,8 @@ func (m model) View() string {
if m.state == stateLogin {
return m.loginView()
} else if m.state == stateSettings {
return m.settingsView()
}
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())
}
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
@@ -629,7 +828,7 @@ func (m model) dashboardView() string {
var statusText string
var statusStyle lipgloss.Style
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
} else {
statusText = " TERMINAL MODE >> Press Ctrl-A for Commands "
@@ -676,7 +875,29 @@ func (m model) dashboardView() string {
Height(m.logViewport.Height).
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() {