From 9de1cd249b8e6ede61380e3f628a4ece6ab04ac2 Mon Sep 17 00:00:00 2001 From: YoungSoo Shin Date: Mon, 15 Dec 2025 18:22:45 +0900 Subject: [PATCH] WIP: new terminal-based powermate frontend Signed-off-by: YoungSoo Shin --- example/powermate-console-frontend/api.go | 177 ++++++ example/powermate-console-frontend/forms.go | 173 ++++++ example/powermate-console-frontend/go.mod | 41 ++ example/powermate-console-frontend/go.sum | 83 +++ example/powermate-console-frontend/main.go | 63 ++ example/powermate-console-frontend/models.go | 141 +++++ .../pb/status.pb.go | 545 ++++++++++++++++++ .../powermate-console-frontend/status.proto | 48 ++ example/powermate-console-frontend/styles.go | 43 ++ .../powermate-console-frontend/terminal.go | 83 +++ example/powermate-console-frontend/update.go | 256 ++++++++ example/powermate-console-frontend/view.go | 100 ++++ 12 files changed, 1753 insertions(+) create mode 100644 example/powermate-console-frontend/api.go create mode 100644 example/powermate-console-frontend/forms.go create mode 100644 example/powermate-console-frontend/go.mod create mode 100644 example/powermate-console-frontend/go.sum create mode 100644 example/powermate-console-frontend/main.go create mode 100644 example/powermate-console-frontend/models.go create mode 100644 example/powermate-console-frontend/pb/status.pb.go create mode 100644 example/powermate-console-frontend/status.proto create mode 100644 example/powermate-console-frontend/styles.go create mode 100644 example/powermate-console-frontend/terminal.go create mode 100644 example/powermate-console-frontend/update.go create mode 100644 example/powermate-console-frontend/view.go diff --git a/example/powermate-console-frontend/api.go b/example/powermate-console-frontend/api.go new file mode 100644 index 0000000..a3ee44a --- /dev/null +++ b/example/powermate-console-frontend/api.go @@ -0,0 +1,177 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gorilla/websocket" + "google.golang.org/protobuf/proto" + "odroid-tui/pb" +) + +func scanWifi(d ConnectionDetails) tea.Cmd { + return func() tea.Msg { + url := fmt.Sprintf("http://%s/api/wifi/scan", d.IP) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+d.Token) + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + var list []WifiAP + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + return err + } + return WifiScanListMsg(list) + } +} + +func fetchSettings(d ConnectionDetails) tea.Cmd { + return func() tea.Msg { + url := fmt.Sprintf("http://%s/api/setting", d.IP) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+d.Token) + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + var cfg SettingsPayload + if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil { + return err + } + return SettingsFetchedMsg(cfg) + } +} + +func saveSettingsMap(d ConnectionDetails, payload interface{}) tea.Cmd { + return func() tea.Msg { + url := fmt.Sprintf("http://%s/api/setting", d.IP) + jsonBytes, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonBytes)) + req.Header.Set("Authorization", "Bearer "+d.Token) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("Save Failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("Save Error: %d", resp.StatusCode) + } + return ActionDoneMsg{} + } +} + +func rebootDevice(d ConnectionDetails) tea.Cmd { + return func() tea.Msg { + url := fmt.Sprintf("http://%s/api/reboot", d.IP) + req, _ := http.NewRequest("POST", url, nil) + req.Header.Set("Authorization", "Bearer "+d.Token) + client := &http.Client{Timeout: 2 * time.Second} + client.Do(req) + return ActionDoneMsg{} + } +} + +func performLogin(d ConnectionDetails) tea.Cmd { + return func() tea.Msg { + url := fmt.Sprintf("http://%s/login", d.IP) + payload := map[string]string{"username": d.Username, "password": d.Password} + jsonBytes, _ := json.Marshal(payload) + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Post(url, "application/json", bytes.NewBuffer(jsonBytes)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("Login Error: %d", resp.StatusCode) + } + var res map[string]string + json.NewDecoder(resp.Body).Decode(&res) + return LoginSuccessMsg(res["token"]) + } +} + +func fetchInitialState(d ConnectionDetails) tea.Cmd { + return func() tea.Msg { + url := fmt.Sprintf("http://%s/api/control", d.IP) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+d.Token) + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + var res InitApiResponse + json.NewDecoder(resp.Body).Decode(&res) + return InitStateMsg{MainOn: res.Load12vOn, UsbOn: res.Load5vOn} + } +} + +func toggleLoad(d ConnectionDetails, key string, state bool) tea.Cmd { + return func() tea.Msg { + url := fmt.Sprintf("http://%s/api/control", d.IP) + payload := map[string]interface{}{key: state} + jsonBytes, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonBytes)) + req.Header.Set("Authorization", "Bearer "+d.Token) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 1 * time.Second} + client.Do(req) + return nil + } +} + +func connectWebSocket(d ConnectionDetails) tea.Cmd { + return func() tea.Msg { + u := fmt.Sprintf("ws://%s/ws?token=%s", d.IP, d.Token) + conn, _, err := websocket.DefaultDialer.Dial(u, nil) + if err != nil { + return err + } + return waitForWebSocketMessage(conn)() + } +} + +func waitForWebSocketMessage(conn *websocket.Conn) tea.Cmd { + return func() tea.Msg { + _, message, err := conn.ReadMessage() + if err != nil { + return err + } + var status pb.StatusMessage + if err := proto.Unmarshal(message, &status); err == nil { + conv := func(s *pb.SensorChannelData) *PowerMetrics { + if s == nil { + return nil + } + return &PowerMetrics{Voltage: float64(s.GetVoltage()), Current: float64(s.GetCurrent()), Power: float64(s.GetPower())} + } + msg := WsPartialMsg{Conn: conn} + if s := status.GetSensorData(); s != nil { + msg.Vin = conv(s.GetVin()) + msg.Main = conv(s.GetMain()) + msg.Usb = conv(s.GetUsb()) + } + if w := status.GetWifiStatus(); w != nil { + msg.Wifi = &WifiInfo{SSID: w.GetSsid(), RSSI: int(w.GetRssi())} + } + if sw := status.GetSwStatus(); sw != nil { + msg.Sw = &SwitchInfo{MainOn: sw.GetMain(), UsbOn: sw.GetUsb()} + } + return msg + } + return waitForWebSocketMessage(conn) + } +} diff --git a/example/powermate-console-frontend/forms.go b/example/powermate-console-frontend/forms.go new file mode 100644 index 0000000..fe37873 --- /dev/null +++ b/example/powermate-console-frontend/forms.go @@ -0,0 +1,173 @@ +package main + +import ( + "fmt" + "github.com/charmbracelet/huh" +) + +func (m *model) initLoginForm() { + m.loginForm = huh.NewForm( + huh.NewGroup( + huh.NewNote().Title("ODROID Connector").Description("Enter credentials"), + huh.NewInput().Title("IP Address").Value(&m.formData.IP), + huh.NewInput().Title("Username").Value(&m.formData.Username), + huh.NewInput().Title("Password").Value(&m.formData.Password).EchoMode(huh.EchoModePassword), + ), + ).WithTheme(huh.ThemeBase()) +} + +func (m *model) initSettingsMenu() { + *m.menuSelection = "scan" + m.settingsForm = huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Settings Menu"). + Key("menu_select"). + Options( + huh.NewOption("1. Scan Networks", "scan"), + huh.NewOption("2. Manual Connection", "manual"), + huh.NewOption("3. IP Configuration", "ip"), + huh.NewOption("4. AP Mode Settings", "ap"), + huh.NewOption("5. System (UART/Sensor)", "system"), + huh.NewOption("6. Safety (Current Limits)", "safety"), + huh.NewOption("7. Account Settings", "account"), + huh.NewOption("8. Reboot Device", "reboot"), + huh.NewOption("Esc. Exit", "exit"), + ). + Value(m.menuSelection), + ), + ).WithTheme(huh.ThemeDracula()) +} + +func (m *model) initWifiScanForm() { + opts := make([]huh.Option[string], 0) + for _, ap := range m.wifiScanList { + label := fmt.Sprintf("%s (%ddBm, %s)", ap.SSID, ap.RSSI, ap.AuthMode) + opts = append(opts, huh.NewOption(label, ap.SSID)) + } + if len(opts) == 0 { + opts = append(opts, huh.NewOption("(No Networks Found - Back)", "")) + } + + // [수정] 터미널 높이를 계산하여 리스트 최대 높이 제한 (스크롤 활성화) + listHeight := 10 + if m.height > 20 { + listHeight = m.height - 10 // 여백 고려 + } + + m.settingsForm = huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Select Wi-Fi Network"). + Key("scan_ssid"). + Options(opts...). + Value(&m.settingsData.WifiSSID). + WithHeight(listHeight), // [핵심] 높이 제한으로 터미널 뚫림 방지 + ), + ).WithTheme(huh.ThemeDracula()) +} + +func (m *model) initWifiForm() { + m.settingsForm = huh.NewForm( + huh.NewGroup( + huh.NewNote().Title("Connect to Wi-Fi"), + huh.NewInput().Title("SSID").Value(&m.settingsData.WifiSSID), + huh.NewInput().Title("Password").Value(&m.settingsData.WifiPass).EchoMode(huh.EchoModePassword), + ), + ).WithTheme(huh.ThemeDracula()) +} + +func (m *model) initIPForm() { + m.settingsForm = huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Network Type"). + Options( + huh.NewOption("DHCP", "dhcp"), + huh.NewOption("Static", "static"), + ). + Value(&m.settingsData.NetType), + ), + huh.NewGroup( + huh.NewNote().Title("Static IP Details"), + huh.NewInput().Title("IP Address").Value(&m.settingsData.IP), + huh.NewInput().Title("Gateway").Value(&m.settingsData.Gateway), + huh.NewInput().Title("Subnet Mask").Value(&m.settingsData.Subnet), + huh.NewInput().Title("DNS 1").Value(&m.settingsData.DNS1), + huh.NewInput().Title("DNS 2").Value(&m.settingsData.DNS2), + ).WithHideFunc(func() bool { + // [수정] DHCP일 때 완벽히 숨김 처리 + return m.settingsData.NetType != "static" + }), + ).WithTheme(huh.ThemeDracula()) +} + +func (m *model) initAPForm() { + m.settingsForm = huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("WiFi Mode"). + Options( + huh.NewOption("Station Only (STA)", "sta"), + huh.NewOption("AP + Station (APSTA)", "apsta"), + ). + Value(&m.settingsData.Mode), + ), + huh.NewGroup( + huh.NewNote().Title("AP Settings"), + huh.NewInput().Title("AP SSID").Value(&m.settingsData.APSSID), + huh.NewInput().Title("AP Password").Value(&m.settingsData.APPass).EchoMode(huh.EchoModePassword), + ).WithHideFunc(func() bool { + // [수정] APSTA가 아닐 때 숨김 처리 + return m.settingsData.Mode != "apsta" + }), + ).WithTheme(huh.ThemeDracula()) +} + +func (m *model) initSystemForm() { + m.settingsForm = huh.NewForm( + huh.NewGroup( + huh.NewNote().Title("System Configuration"), + huh.NewSelect[string](). + Title("UART Baudrate"). + Options( + huh.NewOption("9600", "9600"), + huh.NewOption("19200", "19200"), + huh.NewOption("38400", "38400"), + huh.NewOption("57600", "57600"), + huh.NewOption("115200", "115200"), + huh.NewOption("1500000", "1500000"), + ). + Value(&m.settingsData.Baudrate), + huh.NewInput(). + Title("Sensor Period (ms)"). + Value(&m.settingsData.Period), + ), + ).WithTheme(huh.ThemeDracula()) +} + +func (m *model) initSafetyForm() { + vinStr := fmt.Sprintf("%.2f", m.settingsData.VinLimit) + mainStr := fmt.Sprintf("%.2f", m.settingsData.MainLimit) + usbStr := fmt.Sprintf("%.2f", m.settingsData.UsbLimit) + + m.settingsForm = huh.NewForm( + huh.NewGroup( + huh.NewNote().Title("Current Limits (Ampere)"), + // Key를 명시하여 update.go에서 안전하게 파싱 + huh.NewInput().Title("VIN Limit").Value(&vinStr).Key("vin_limit"), + huh.NewInput().Title("Main (12V) Limit").Value(&mainStr).Key("main_limit"), + huh.NewInput().Title("USB (5V) Limit").Value(&usbStr).Key("usb_limit"), + ), + ).WithTheme(huh.ThemeDracula()) +} + +func (m *model) initAccountForm() { + m.settingsForm = huh.NewForm( + huh.NewGroup( + huh.NewNote().Title("Change Credentials"), + huh.NewInput().Title("New Username").Value(&m.settingsData.NewUser), + huh.NewInput().Title("New Password").Value(&m.settingsData.NewPass).EchoMode(huh.EchoModePassword), + ), + ).WithTheme(huh.ThemeDracula()) +} diff --git a/example/powermate-console-frontend/go.mod b/example/powermate-console-frontend/go.mod new file mode 100644 index 0000000..ca7ea3d --- /dev/null +++ b/example/powermate-console-frontend/go.mod @@ -0,0 +1,41 @@ +module odroid-tui + +go 1.24.0 + +toolchain go1.24.11 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/gorilla/websocket v1.5.3 + github.com/guptarohit/asciigraph v0.7.3 + golang.org/x/term v0.38.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/example/powermate-console-frontend/go.sum b/example/powermate-console-frontend/go.sum new file mode 100644 index 0000000..f9fa12d --- /dev/null +++ b/example/powermate-console-frontend/go.sum @@ -0,0 +1,83 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/guptarohit/asciigraph v0.7.3 h1:p05XDDn7cBTWiBqWb30mrwxd6oU0claAjqeytllnsPY= +github.com/guptarohit/asciigraph v0.7.3/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/example/powermate-console-frontend/main.go b/example/powermate-console-frontend/main.go new file mode 100644 index 0000000..93a5d05 --- /dev/null +++ b/example/powermate-console-frontend/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "flag" + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" +) + +func initialModel(ip, user, pass string) model { + formPtr := &ConnectionDetails{IP: "192.168.0.100", Username: "admin"} + if ip != "" { + formPtr.IP = ip + } + if user != "" { + formPtr.Username = user + } + if pass != "" { + formPtr.Password = pass + } + + connPtr := &ConnectionDetails{IP: ip, Username: user, Password: pass} + + // [수정] menuSelection 초기화 + menuSel := "scan" + + m := model{ + state: StateLogin, + connDetails: connPtr, + formData: formPtr, + menuSelection: &menuSel, // 포인터 할당 + settingsData: SettingsPayload{ + Baudrate: "115200", + Period: "1000", + }, + } + + if ip != "" && user != "" && pass != "" { + return m + } + m.initLoginForm() + return m +} + +func (m model) Init() tea.Cmd { + if m.connDetails.IP != "" && m.connDetails.Username != "" && m.connDetails.Password != "" { + return performLogin(*m.connDetails) + } + return m.loginForm.Init() +} + +func main() { + host := flag.String("h", "", "IP") + user := flag.String("u", "", "User") + pass := flag.String("p", "", "Pass") + flag.Parse() + + if _, err := tea.NewProgram(initialModel(*host, *user, *pass), tea.WithAltScreen()).Run(); err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } +} diff --git a/example/powermate-console-frontend/models.go b/example/powermate-console-frontend/models.go new file mode 100644 index 0000000..72e9890 --- /dev/null +++ b/example/powermate-console-frontend/models.go @@ -0,0 +1,141 @@ +package main + +import ( + "github.com/charmbracelet/huh" + "github.com/gorilla/websocket" +) + +// --- [상수 및 Enum] --- +type AppState int + +const ( + StateLogin AppState = iota + StateDashboard + StateTerminal + StateSettings +) + +type SettingSubState int + +const ( + SubMenu SettingSubState = iota + SubWifiScanList + SubWifiConnect + SubIPConfig + SubAPMode + SubSystem + SubSafety + SubAccount +) + +// --- [데이터 구조체] --- +type ConnectionDetails struct { + IP, Username, Password, Token string +} + +type PowerMetrics struct { + Voltage, Current, Power float64 +} + +type WifiInfo struct { + SSID string + RSSI int +} + +type SwitchInfo struct { + MainOn, UsbOn bool +} + +type DeviceStatus struct { + Vin PowerMetrics + Main PowerMetrics + Usb PowerMetrics + Wifi WifiInfo + Sw SwitchInfo +} + +type InitApiResponse struct { + Load12vOn bool `json:"load_12v_on"` + Load5vOn bool `json:"load_5v_on"` +} + +type WifiAP struct { + SSID string `json:"ssid"` + RSSI int `json:"rssi"` + AuthMode string `json:"authmode"` +} + +type SettingsPayload struct { + Connected bool `json:"connected,omitempty"` + CurSSID string `json:"ssid,omitempty"` + CurIP struct { + IP string `json:"ip"` + Gateway string `json:"gateway"` + Subnet string `json:"subnet"` + DNS1 string `json:"dns1"` + DNS2 string `json:"dns2"` + } `json:"ip,omitempty"` + + WifiSSID string `json:"ssid,omitempty"` + WifiPass string `json:"password,omitempty"` + + NetType string `json:"net_type,omitempty"` + IP string `json:"ip,omitempty"` + Gateway string `json:"gateway,omitempty"` + Subnet string `json:"subnet,omitempty"` + DNS1 string `json:"dns1,omitempty"` + DNS2 string `json:"dns2,omitempty"` + + Mode string `json:"mode,omitempty"` + APSSID string `json:"ap_ssid,omitempty"` + APPass string `json:"ap_password,omitempty"` + + Baudrate string `json:"baudrate,omitempty"` + Period string `json:"period,omitempty"` + + VinLimit float64 `json:"vin_current_limit,omitempty"` + MainLimit float64 `json:"main_current_limit,omitempty"` + UsbLimit float64 `json:"usb_current_limit,omitempty"` + + NewUser string `json:"new_username,omitempty"` + NewPass string `json:"new_password,omitempty"` +} + +// --- [메인 모델] --- +type model struct { + state AppState + connDetails *ConnectionDetails + formData *ConnectionDetails + + status DeviceStatus + width int + height int + + // Settings State + settingSubState SettingSubState + menuSelection *string // [수정] 포인터로 변경 (메뉴 선택 버그 해결 핵심) + scanning bool // [추가] Wi-Fi 스캔 중 상태 표시 + settingsData SettingsPayload + wifiScanList []WifiAP + + // Forms + loginForm *huh.Form + settingsForm *huh.Form + + err error +} + +// --- [Tea Messages] --- +type TerminalFinishedMsg struct{} +type LoginSuccessMsg string +type InitStateMsg SwitchInfo +type SettingsFetchedMsg SettingsPayload +type ActionDoneMsg struct{} +type WifiScanListMsg []WifiAP + +type WsPartialMsg struct { + Vin, Main, Usb *PowerMetrics + Wifi *WifiInfo + Sw *SwitchInfo + Conn *websocket.Conn +} diff --git a/example/powermate-console-frontend/pb/status.pb.go b/example/powermate-console-frontend/pb/status.pb.go new file mode 100644 index 0000000..03e53ab --- /dev/null +++ b/example/powermate-console-frontend/pb/status.pb.go @@ -0,0 +1,545 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc v3.21.12 +// source: status.proto + +package pb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Represents data for a single sensor channel +type SensorChannelData struct { + state protoimpl.MessageState `protogen:"open.v1"` + Voltage float32 `protobuf:"fixed32,1,opt,name=voltage,proto3" json:"voltage,omitempty"` + Current float32 `protobuf:"fixed32,2,opt,name=current,proto3" json:"current,omitempty"` + Power float32 `protobuf:"fixed32,3,opt,name=power,proto3" json:"power,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SensorChannelData) Reset() { + *x = SensorChannelData{} + mi := &file_status_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SensorChannelData) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SensorChannelData) ProtoMessage() {} + +func (x *SensorChannelData) ProtoReflect() protoreflect.Message { + mi := &file_status_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SensorChannelData.ProtoReflect.Descriptor instead. +func (*SensorChannelData) Descriptor() ([]byte, []int) { + return file_status_proto_rawDescGZIP(), []int{0} +} + +func (x *SensorChannelData) GetVoltage() float32 { + if x != nil { + return x.Voltage + } + return 0 +} + +func (x *SensorChannelData) GetCurrent() float32 { + if x != nil { + return x.Current + } + return 0 +} + +func (x *SensorChannelData) GetPower() float32 { + if x != nil { + return x.Power + } + return 0 +} + +// Contains data for all sensor channels and system info +type SensorData struct { + state protoimpl.MessageState `protogen:"open.v1"` + Usb *SensorChannelData `protobuf:"bytes,1,opt,name=usb,proto3" json:"usb,omitempty"` + Main *SensorChannelData `protobuf:"bytes,2,opt,name=main,proto3" json:"main,omitempty"` + Vin *SensorChannelData `protobuf:"bytes,3,opt,name=vin,proto3" json:"vin,omitempty"` + TimestampMs uint64 `protobuf:"varint,4,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"` + UptimeMs uint64 `protobuf:"varint,5,opt,name=uptime_ms,json=uptimeMs,proto3" json:"uptime_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SensorData) Reset() { + *x = SensorData{} + mi := &file_status_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SensorData) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SensorData) ProtoMessage() {} + +func (x *SensorData) ProtoReflect() protoreflect.Message { + mi := &file_status_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SensorData.ProtoReflect.Descriptor instead. +func (*SensorData) Descriptor() ([]byte, []int) { + return file_status_proto_rawDescGZIP(), []int{1} +} + +func (x *SensorData) GetUsb() *SensorChannelData { + if x != nil { + return x.Usb + } + return nil +} + +func (x *SensorData) GetMain() *SensorChannelData { + if x != nil { + return x.Main + } + return nil +} + +func (x *SensorData) GetVin() *SensorChannelData { + if x != nil { + return x.Vin + } + return nil +} + +func (x *SensorData) GetTimestampMs() uint64 { + if x != nil { + return x.TimestampMs + } + return 0 +} + +func (x *SensorData) GetUptimeMs() uint64 { + if x != nil { + return x.UptimeMs + } + return 0 +} + +// Contains WiFi connection status +type WifiStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + Connected bool `protobuf:"varint,1,opt,name=connected,proto3" json:"connected,omitempty"` + Ssid string `protobuf:"bytes,2,opt,name=ssid,proto3" json:"ssid,omitempty"` + Rssi int32 `protobuf:"varint,3,opt,name=rssi,proto3" json:"rssi,omitempty"` + IpAddress string `protobuf:"bytes,4,opt,name=ip_address,json=ipAddress,proto3" json:"ip_address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WifiStatus) Reset() { + *x = WifiStatus{} + mi := &file_status_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WifiStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WifiStatus) ProtoMessage() {} + +func (x *WifiStatus) ProtoReflect() protoreflect.Message { + mi := &file_status_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WifiStatus.ProtoReflect.Descriptor instead. +func (*WifiStatus) Descriptor() ([]byte, []int) { + return file_status_proto_rawDescGZIP(), []int{2} +} + +func (x *WifiStatus) GetConnected() bool { + if x != nil { + return x.Connected + } + return false +} + +func (x *WifiStatus) GetSsid() string { + if x != nil { + return x.Ssid + } + return "" +} + +func (x *WifiStatus) GetRssi() int32 { + if x != nil { + return x.Rssi + } + return 0 +} + +func (x *WifiStatus) GetIpAddress() string { + if x != nil { + return x.IpAddress + } + return "" +} + +// Contains raw UART data +type UartData struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UartData) Reset() { + *x = UartData{} + mi := &file_status_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UartData) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UartData) ProtoMessage() {} + +func (x *UartData) ProtoReflect() protoreflect.Message { + mi := &file_status_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UartData.ProtoReflect.Descriptor instead. +func (*UartData) Descriptor() ([]byte, []int) { + return file_status_proto_rawDescGZIP(), []int{3} +} + +func (x *UartData) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +// Contains load sw status +type LoadSwStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + Main bool `protobuf:"varint,1,opt,name=main,proto3" json:"main,omitempty"` + Usb bool `protobuf:"varint,2,opt,name=usb,proto3" json:"usb,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LoadSwStatus) Reset() { + *x = LoadSwStatus{} + mi := &file_status_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LoadSwStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LoadSwStatus) ProtoMessage() {} + +func (x *LoadSwStatus) ProtoReflect() protoreflect.Message { + mi := &file_status_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LoadSwStatus.ProtoReflect.Descriptor instead. +func (*LoadSwStatus) Descriptor() ([]byte, []int) { + return file_status_proto_rawDescGZIP(), []int{4} +} + +func (x *LoadSwStatus) GetMain() bool { + if x != nil { + return x.Main + } + return false +} + +func (x *LoadSwStatus) GetUsb() bool { + if x != nil { + return x.Usb + } + return false +} + +// Top-level message for all websocket communication +type StatusMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *StatusMessage_SensorData + // *StatusMessage_WifiStatus + // *StatusMessage_SwStatus + // *StatusMessage_UartData + Payload isStatusMessage_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatusMessage) Reset() { + *x = StatusMessage{} + mi := &file_status_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatusMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatusMessage) ProtoMessage() {} + +func (x *StatusMessage) ProtoReflect() protoreflect.Message { + mi := &file_status_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatusMessage.ProtoReflect.Descriptor instead. +func (*StatusMessage) Descriptor() ([]byte, []int) { + return file_status_proto_rawDescGZIP(), []int{5} +} + +func (x *StatusMessage) GetPayload() isStatusMessage_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *StatusMessage) GetSensorData() *SensorData { + if x != nil { + if x, ok := x.Payload.(*StatusMessage_SensorData); ok { + return x.SensorData + } + } + return nil +} + +func (x *StatusMessage) GetWifiStatus() *WifiStatus { + if x != nil { + if x, ok := x.Payload.(*StatusMessage_WifiStatus); ok { + return x.WifiStatus + } + } + return nil +} + +func (x *StatusMessage) GetSwStatus() *LoadSwStatus { + if x != nil { + if x, ok := x.Payload.(*StatusMessage_SwStatus); ok { + return x.SwStatus + } + } + return nil +} + +func (x *StatusMessage) GetUartData() *UartData { + if x != nil { + if x, ok := x.Payload.(*StatusMessage_UartData); ok { + return x.UartData + } + } + return nil +} + +type isStatusMessage_Payload interface { + isStatusMessage_Payload() +} + +type StatusMessage_SensorData struct { + SensorData *SensorData `protobuf:"bytes,1,opt,name=sensor_data,json=sensorData,proto3,oneof"` +} + +type StatusMessage_WifiStatus struct { + WifiStatus *WifiStatus `protobuf:"bytes,2,opt,name=wifi_status,json=wifiStatus,proto3,oneof"` +} + +type StatusMessage_SwStatus struct { + SwStatus *LoadSwStatus `protobuf:"bytes,3,opt,name=sw_status,json=swStatus,proto3,oneof"` +} + +type StatusMessage_UartData struct { + UartData *UartData `protobuf:"bytes,4,opt,name=uart_data,json=uartData,proto3,oneof"` +} + +func (*StatusMessage_SensorData) isStatusMessage_Payload() {} + +func (*StatusMessage_WifiStatus) isStatusMessage_Payload() {} + +func (*StatusMessage_SwStatus) isStatusMessage_Payload() {} + +func (*StatusMessage_UartData) isStatusMessage_Payload() {} + +var File_status_proto protoreflect.FileDescriptor + +const file_status_proto_rawDesc = "" + + "\n" + + "\fstatus.proto\"]\n" + + "\x11SensorChannelData\x12\x18\n" + + "\avoltage\x18\x01 \x01(\x02R\avoltage\x12\x18\n" + + "\acurrent\x18\x02 \x01(\x02R\acurrent\x12\x14\n" + + "\x05power\x18\x03 \x01(\x02R\x05power\"\xc0\x01\n" + + "\n" + + "SensorData\x12$\n" + + "\x03usb\x18\x01 \x01(\v2\x12.SensorChannelDataR\x03usb\x12&\n" + + "\x04main\x18\x02 \x01(\v2\x12.SensorChannelDataR\x04main\x12$\n" + + "\x03vin\x18\x03 \x01(\v2\x12.SensorChannelDataR\x03vin\x12!\n" + + "\ftimestamp_ms\x18\x04 \x01(\x04R\vtimestampMs\x12\x1b\n" + + "\tuptime_ms\x18\x05 \x01(\x04R\buptimeMs\"q\n" + + "\n" + + "WifiStatus\x12\x1c\n" + + "\tconnected\x18\x01 \x01(\bR\tconnected\x12\x12\n" + + "\x04ssid\x18\x02 \x01(\tR\x04ssid\x12\x12\n" + + "\x04rssi\x18\x03 \x01(\x05R\x04rssi\x12\x1d\n" + + "\n" + + "ip_address\x18\x04 \x01(\tR\tipAddress\"\x1e\n" + + "\bUartData\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\"4\n" + + "\fLoadSwStatus\x12\x12\n" + + "\x04main\x18\x01 \x01(\bR\x04main\x12\x10\n" + + "\x03usb\x18\x02 \x01(\bR\x03usb\"\xd2\x01\n" + + "\rStatusMessage\x12.\n" + + "\vsensor_data\x18\x01 \x01(\v2\v.SensorDataH\x00R\n" + + "sensorData\x12.\n" + + "\vwifi_status\x18\x02 \x01(\v2\v.WifiStatusH\x00R\n" + + "wifiStatus\x12,\n" + + "\tsw_status\x18\x03 \x01(\v2\r.LoadSwStatusH\x00R\bswStatus\x12(\n" + + "\tuart_data\x18\x04 \x01(\v2\t.UartDataH\x00R\buartDataB\t\n" + + "\apayloadB\x0fZ\rodroid-tui/pbb\x06proto3" + +var ( + file_status_proto_rawDescOnce sync.Once + file_status_proto_rawDescData []byte +) + +func file_status_proto_rawDescGZIP() []byte { + file_status_proto_rawDescOnce.Do(func() { + file_status_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_status_proto_rawDesc), len(file_status_proto_rawDesc))) + }) + return file_status_proto_rawDescData +} + +var file_status_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_status_proto_goTypes = []any{ + (*SensorChannelData)(nil), // 0: SensorChannelData + (*SensorData)(nil), // 1: SensorData + (*WifiStatus)(nil), // 2: WifiStatus + (*UartData)(nil), // 3: UartData + (*LoadSwStatus)(nil), // 4: LoadSwStatus + (*StatusMessage)(nil), // 5: StatusMessage +} +var file_status_proto_depIdxs = []int32{ + 0, // 0: SensorData.usb:type_name -> SensorChannelData + 0, // 1: SensorData.main:type_name -> SensorChannelData + 0, // 2: SensorData.vin:type_name -> SensorChannelData + 1, // 3: StatusMessage.sensor_data:type_name -> SensorData + 2, // 4: StatusMessage.wifi_status:type_name -> WifiStatus + 4, // 5: StatusMessage.sw_status:type_name -> LoadSwStatus + 3, // 6: StatusMessage.uart_data:type_name -> UartData + 7, // [7:7] is the sub-list for method output_type + 7, // [7:7] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name +} + +func init() { file_status_proto_init() } +func file_status_proto_init() { + if File_status_proto != nil { + return + } + file_status_proto_msgTypes[5].OneofWrappers = []any{ + (*StatusMessage_SensorData)(nil), + (*StatusMessage_WifiStatus)(nil), + (*StatusMessage_SwStatus)(nil), + (*StatusMessage_UartData)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_status_proto_rawDesc), len(file_status_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_status_proto_goTypes, + DependencyIndexes: file_status_proto_depIdxs, + MessageInfos: file_status_proto_msgTypes, + }.Build() + File_status_proto = out.File + file_status_proto_goTypes = nil + file_status_proto_depIdxs = nil +} diff --git a/example/powermate-console-frontend/status.proto b/example/powermate-console-frontend/status.proto new file mode 100644 index 0000000..402fe33 --- /dev/null +++ b/example/powermate-console-frontend/status.proto @@ -0,0 +1,48 @@ +syntax = "proto3"; + +option go_package = "odroid-tui/pb"; + +// Represents data for a single sensor channel +message SensorChannelData { + float voltage = 1; + float current = 2; + float power = 3; +} + +// Contains data for all sensor channels and system info +message SensorData { + SensorChannelData usb = 1; + SensorChannelData main = 2; + SensorChannelData vin = 3; + uint64 timestamp_ms = 4; + uint64 uptime_ms = 5; +} + +// Contains WiFi connection status +message WifiStatus { + bool connected = 1; + string ssid = 2; + int32 rssi = 3; + string ip_address = 4; +} + +// Contains raw UART data +message UartData { + bytes data = 1; +} + +// Contains load sw status +message LoadSwStatus { + bool main = 1; + bool usb = 2; +} + +// Top-level message for all websocket communication +message StatusMessage { + oneof payload { + SensorData sensor_data = 1; + WifiStatus wifi_status = 2; + LoadSwStatus sw_status = 3; + UartData uart_data = 4; + } +} \ No newline at end of file diff --git a/example/powermate-console-frontend/styles.go b/example/powermate-console-frontend/styles.go new file mode 100644 index 0000000..9faa405 --- /dev/null +++ b/example/powermate-console-frontend/styles.go @@ -0,0 +1,43 @@ +package main + +import "github.com/charmbracelet/lipgloss" + +// --- [색상 정의] --- +const ( + ColorPurple = lipgloss.Color("#7D56F4") + ColorGreen = lipgloss.Color("#00AF87") + ColorRed = lipgloss.Color("#FF5F87") + ColorGray = lipgloss.Color("#404040") + ColorText = lipgloss.Color("#FAFAFA") + ColorBlue = lipgloss.Color("#335588") +) + +// --- [스타일 정의] --- +var ( + loginBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorPurple). + Padding(1, 2). + Width(40). + Align(lipgloss.Left) + + settingsBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorBlue). + Padding(1, 2). + Width(60). + // [수정] Height, MinHeight 제거: 내용물에 따라 높이 자동 조절 (숨겨진 필드 대응) + Align(lipgloss.Left) + + panelBaseStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Padding(0, 1). + Align(lipgloss.Center) + + titleStyle = lipgloss.NewStyle().Bold(true).MarginBottom(1) + lblStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).MarginRight(1) + + statusOn = lipgloss.NewStyle().Foreground(ColorGreen).Bold(true) + statusOff = lipgloss.NewStyle().Foreground(ColorRed).Bold(true) + errStyle = lipgloss.NewStyle().Foreground(ColorRed) +) diff --git a/example/powermate-console-frontend/terminal.go b/example/powermate-console-frontend/terminal.go new file mode 100644 index 0000000..baf8bc3 --- /dev/null +++ b/example/powermate-console-frontend/terminal.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gorilla/websocket" + "golang.org/x/term" + "google.golang.org/protobuf/proto" + "odroid-tui/pb" +) + +func startRawTerminal(m *model) tea.Cmd { + return func() tea.Msg { + fd := int(os.Stdin.Fd()) + oldState, err := term.MakeRaw(fd) + if err != nil { + return err + } + defer term.Restore(fd, oldState) + fmt.Print("\033[2J\033[H") + fmt.Printf("\r\n\033[32m--- Terminal Connected (Ctrl+A to exit) ---\033[0m\r\n") + + u := fmt.Sprintf("ws://%s/ws?token=%s", m.connDetails.IP, m.connDetails.Token) + ws, _, err := websocket.DefaultDialer.Dial(u, nil) + if err != nil { + return err + } + defer ws.Close() + + done := make(chan struct{}) + sendChan := make(chan []byte, 256) + go func() { + for d := range sendChan { + ws.WriteMessage(websocket.BinaryMessage, d) + } + }() + go func() { + for { + _, msg, err := ws.ReadMessage() + if err != nil { + close(done) + return + } + var st pb.StatusMessage + if proto.Unmarshal(msg, &st) == nil { + if u := st.GetUartData(); u != nil { + os.Stdout.Write(u.GetData()) + } + } else { + os.Stdout.Write(msg) + } + } + }() + + buf := make([]byte, 1024) + for { + n, err := os.Stdin.Read(buf) + if err != nil { + close(sendChan) + return TerminalFinishedMsg{} + } + if n > 0 { + for i := 0; i < n; i++ { + if buf[i] == 1 { + ws.Close() + return TerminalFinishedMsg{} + } + } + d := make([]byte, n) + copy(d, buf[:n]) + sendChan <- d + } + select { + case <-done: + close(sendChan) + return TerminalFinishedMsg{} + default: + } + } + } +} diff --git a/example/powermate-console-frontend/update.go b/example/powermate-console-frontend/update.go new file mode 100644 index 0000000..60a6deb --- /dev/null +++ b/example/powermate-console-frontend/update.go @@ -0,0 +1,256 @@ +package main + +import ( + "strconv" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" +) + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } + + if m.state == StateSettings { + if m.scanning { + return m, nil + } + if msg.Type == tea.KeyEsc { + if m.settingSubState != SubMenu { + m.settingSubState = SubMenu + m.initSettingsMenu() + return m, m.settingsForm.Init() + } + m.state = StateDashboard + m.err = nil + return m, nil + } + } + + if m.state == StateDashboard { + switch msg.String() { + case "enter": + m.state = StateTerminal + return m, tea.Sequence(tea.ExitAltScreen, startRawTerminal(&m)) + case "s": + return m, fetchSettings(*m.connDetails) + case "u": + return m, toggleLoad(*m.connDetails, "load_5v_on", !m.status.Sw.UsbOn) + case "m": + return m, toggleLoad(*m.connDetails, "load_12v_on", !m.status.Sw.MainOn) + case "q": + return m, tea.Quit + } + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case LoginSuccessMsg: + m.connDetails.Token = string(msg) + m.state = StateDashboard + return m, tea.Batch( + connectWebSocket(*m.connDetails), + fetchInitialState(*m.connDetails), + ) + + case InitStateMsg: + m.status.Sw.MainOn = msg.MainOn + m.status.Sw.UsbOn = msg.UsbOn + return m, nil + + case SettingsFetchedMsg: + m.settingsData = SettingsPayload(msg) + if m.settingsData.CurIP.IP != "" { + m.settingsData.IP = m.settingsData.CurIP.IP + m.settingsData.Gateway = m.settingsData.CurIP.Gateway + m.settingsData.Subnet = m.settingsData.CurIP.Subnet + m.settingsData.DNS1 = m.settingsData.CurIP.DNS1 + m.settingsData.DNS2 = m.settingsData.CurIP.DNS2 + } + + m.settingSubState = SubMenu + m.initSettingsMenu() + m.state = StateSettings + return m, m.settingsForm.Init() + + case WifiScanListMsg: + m.scanning = false + m.wifiScanList = []WifiAP(msg) + m.settingSubState = SubWifiScanList + m.initWifiScanForm() + return m, m.settingsForm.Init() + + case ActionDoneMsg: + m.err = nil + return m, fetchSettings(*m.connDetails) + + case WsPartialMsg: + if msg.Vin != nil { + m.status.Vin = *msg.Vin + } + if msg.Main != nil { + m.status.Main = *msg.Main + } + if msg.Usb != nil { + m.status.Usb = *msg.Usb + } + if msg.Wifi != nil { + m.status.Wifi = *msg.Wifi + } + if msg.Sw != nil { + m.status.Sw = *msg.Sw + } + return m, waitForWebSocketMessage(msg.Conn) + + case TerminalFinishedMsg: + m.state = StateDashboard + return m, tea.Sequence(tea.EnterAltScreen, tea.ClearScreen) + + case error: + m.scanning = false + m.err = msg + if m.state == StateLogin { + if m.loginForm == nil { + m.initLoginForm() + } + return m, m.loginForm.Init() + } + return m, nil + } + + // 1. Login Form Update + if m.state == StateLogin && m.loginForm != nil { + form, cmd := m.loginForm.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.loginForm = f + if m.loginForm.State == huh.StateCompleted { + *m.connDetails = *m.formData + return m, performLogin(*m.connDetails) + } + } + return m, cmd + } + + // 2. Settings Form Update + if m.state == StateSettings && m.settingsForm != nil { + form, cmd := m.settingsForm.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.settingsForm = f + + if m.settingsForm.State == huh.StateCompleted { + + if m.settingSubState == SubMenu { + selection := f.GetString("menu_select") + + switch selection { + case "scan": + m.scanning = true + return m, scanWifi(*m.connDetails) + case "manual": + m.settingSubState = SubWifiConnect + m.initWifiForm() + case "ip": + m.settingSubState = SubIPConfig + m.initIPForm() + case "ap": + m.settingSubState = SubAPMode + m.initAPForm() + case "system": + m.settingSubState = SubSystem + m.initSystemForm() + case "safety": + m.settingSubState = SubSafety + m.initSafetyForm() + case "account": + m.settingSubState = SubAccount + m.initAccountForm() + case "reboot": + return m, rebootDevice(*m.connDetails) + case "exit": + m.state = StateDashboard + return m, nil + } + return m, m.settingsForm.Init() + } + + if m.settingSubState == SubWifiScanList { + selectedSSID := f.GetString("scan_ssid") + if selectedSSID == "" { + m.settingSubState = SubMenu + m.initSettingsMenu() + return m, m.settingsForm.Init() + } + m.settingsData.WifiSSID = selectedSSID + m.settingSubState = SubWifiConnect + m.initWifiForm() + return m, m.settingsForm.Init() + } + + switch m.settingSubState { + case SubWifiConnect: + payload := map[string]string{ + "ssid": m.settingsData.WifiSSID, + "password": m.settingsData.WifiPass, + } + return m, saveSettingsMap(*m.connDetails, payload) + + case SubIPConfig: + payload := map[string]interface{}{"net_type": m.settingsData.NetType} + if m.settingsData.NetType == "static" { + payload["ip"] = m.settingsData.IP + payload["gateway"] = m.settingsData.Gateway + payload["subnet"] = m.settingsData.Subnet + payload["dns1"] = m.settingsData.DNS1 + payload["dns2"] = m.settingsData.DNS2 + } + return m, saveSettingsMap(*m.connDetails, payload) + + case SubAPMode: + payload := map[string]string{"mode": m.settingsData.Mode} + if m.settingsData.Mode == "apsta" { + payload["ap_ssid"] = m.settingsData.APSSID + payload["ap_password"] = m.settingsData.APPass + } + return m, saveSettingsMap(*m.connDetails, payload) + + case SubSystem: + // [수정] 백엔드 한계로 인해 순차적(Sequential) 전송 필수 + // baudrate 변경 요청 -> period 변경 요청 + return m, tea.Sequence( + saveSettingsMap(*m.connDetails, map[string]interface{}{"baudrate": m.settingsData.Baudrate}), + saveSettingsMap(*m.connDetails, map[string]interface{}{"period": m.settingsData.Period}), + ) + + case SubSafety: + // Safety 설정은 백엔드에서 한 번에 처리가 가능하므로 하나로 묶음 + valVin, _ := strconv.ParseFloat(f.GetString("vin_limit"), 64) + valMain, _ := strconv.ParseFloat(f.GetString("main_limit"), 64) + valUsb, _ := strconv.ParseFloat(f.GetString("usb_limit"), 64) + + payload := map[string]interface{}{ + "vin_current_limit": valVin, + "main_current_limit": valMain, + "usb_current_limit": valUsb, + } + return m, saveSettingsMap(*m.connDetails, payload) + + case SubAccount: + payload := map[string]string{ + "new_username": m.settingsData.NewUser, + "new_password": m.settingsData.NewPass, + } + return m, saveSettingsMap(*m.connDetails, payload) + } + } + } + return m, cmd + } + + return m, nil +} diff --git a/example/powermate-console-frontend/view.go b/example/powermate-console-frontend/view.go new file mode 100644 index 0000000..3ebbdb9 --- /dev/null +++ b/example/powermate-console-frontend/view.go @@ -0,0 +1,100 @@ +package main + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +func (m model) View() string { + errView := "" + if m.err != nil { + errView = fmt.Sprintf("\n%s", errStyle.Render(fmt.Sprintf("ERROR: %v", m.err))) + } + + if m.state == StateLogin { + content := "" + if m.loginForm != nil { + content = m.loginForm.View() + } + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, + loginBoxStyle.Render(content+errView)) + } + + if m.state == StateSettings { + // [수정] 스캔 중이면 로딩 화면 표시 + if m.scanning { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, + settingsBoxStyle.Render("Scanning Wi-Fi Networks...\nPlease wait.")) + } + + content := "" + if m.settingsForm != nil { + content = m.settingsForm.View() + } + + helpMsg := "\n[Enter] Select/Save [Esc] Back/Cancel" + box := settingsBoxStyle.Render(content + lipgloss.NewStyle().Foreground(ColorGray).Render(helpMsg)) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box+errView) + } + + if m.state == StateTerminal { + return "" + } + + // Dashboard + wifiTxt := "WiFi: Disconnected" + if m.status.Wifi.SSID != "" { + wifiTxt = fmt.Sprintf("WiFi: %s (%ddBm)", m.status.Wifi.SSID, m.status.Wifi.RSSI) + } + header := lipgloss.NewStyle().Width(m.width).Background(ColorPurple).Foreground(ColorText).Padding(0, 1).Bold(true).Render(wifiTxt) + + panelW := (m.width - 6) / 3 + if panelW < 22 { + panelW = 22 + } + currPanelStyle := panelBaseStyle.Copy().Width(panelW) + + renderMetric := func(label string, val float64, unit string, color lipgloss.Color) string { + return fmt.Sprintf("%s%s", lblStyle.Render(label), lipgloss.NewStyle().Foreground(color).Bold(true).Render(fmt.Sprintf("%6.2f %s", val, unit))) + } + + pVin := currPanelStyle.Copy().BorderForeground(ColorBlue).Render(lipgloss.JoinVertical(lipgloss.Center, + titleStyle.Foreground(ColorBlue).Render("VIN (Input)"), + renderMetric("VOLT:", m.status.Vin.Voltage, "V", ColorBlue), + renderMetric("CURR:", m.status.Vin.Current, "A", ColorBlue), + renderMetric("POWR:", m.status.Vin.Power, "W", ColorBlue), + )) + + mColor := ColorRed + mTitle := "MAIN [OFF]" + if m.status.Sw.MainOn { + mColor = ColorGreen + mTitle = "MAIN [ON]" + } + pMain := currPanelStyle.Copy().BorderForeground(mColor).Render(lipgloss.JoinVertical(lipgloss.Center, + titleStyle.Foreground(mColor).Render(mTitle), + renderMetric("VOLT:", m.status.Main.Voltage, "V", mColor), + renderMetric("CURR:", m.status.Main.Current, "A", mColor), + renderMetric("POWR:", m.status.Main.Power, "W", mColor), + )) + + uColor := ColorRed + uTitle := "USB [OFF]" + if m.status.Sw.UsbOn { + uColor = ColorGreen + uTitle = "USB [ON]" + } + pUsb := currPanelStyle.Copy().BorderForeground(uColor).Render(lipgloss.JoinVertical(lipgloss.Center, + titleStyle.Foreground(uColor).Render(uTitle), + renderMetric("VOLT:", m.status.Usb.Voltage, "V", uColor), + renderMetric("CURR:", m.status.Usb.Current, "A", uColor), + renderMetric("POWR:", m.status.Usb.Power, "W", uColor), + )) + + panels := lipgloss.PlaceHorizontal(m.width, lipgloss.Center, lipgloss.JoinHorizontal(lipgloss.Top, pVin, pMain, pUsb)) + help := lipgloss.NewStyle().Foreground(ColorGray).Render(" [Enter] Terminal [s] Settings [u] Toggle USB [m] Toggle Main [q] Quit") + footer := lipgloss.PlaceHorizontal(m.width, lipgloss.Center, help) + + return lipgloss.JoinVertical(lipgloss.Left, header, "\n\n", panels, "\n\n", footer+errView) +}