tui simulation

This commit is contained in:
2026-02-07 02:24:26 +01:00
parent 97427bb3c0
commit 7eb052f20e
10 changed files with 1004 additions and 64 deletions

89
internal/tui/logger.go Normal file
View File

@@ -0,0 +1,89 @@
package tui
import (
"fmt"
"sync"
"time"
)
// LogEntry represents a single log entry with timestamp and level
type LogEntry struct {
Time time.Time
Level string // INFO, CONN, TX, RX, ERR
Message string
}
// LogBuffer is a thread-safe circular buffer for log entries
type LogBuffer struct {
mu sync.RWMutex
entries []LogEntry
maxSize int
}
// NewLogBuffer creates a new LogBuffer with the given max size
func NewLogBuffer(maxSize int) *LogBuffer {
return &LogBuffer{
entries: make([]LogEntry, 0, maxSize),
maxSize: maxSize,
}
}
// Log adds a new log entry
func (l *LogBuffer) Log(level, message string) {
l.mu.Lock()
defer l.mu.Unlock()
entry := LogEntry{
Time: time.Now(),
Level: level,
Message: message,
}
l.entries = append(l.entries, entry)
if len(l.entries) > l.maxSize {
l.entries = l.entries[1:]
}
// No p.Send() - TUI refreshes via tick
}
// Info logs an INFO level message
func (l *LogBuffer) Info(format string, args ...any) {
l.Log("INFO", fmt.Sprintf(format, args...))
}
// Conn logs a CONN level message
func (l *LogBuffer) Conn(format string, args ...any) {
l.Log("CONN", fmt.Sprintf(format, args...))
}
// TX logs a TX level message
func (l *LogBuffer) TX(format string, args ...any) {
l.Log("TX", fmt.Sprintf(format, args...))
}
// RX logs an RX level message
func (l *LogBuffer) RX(format string, args ...any) {
l.Log("RX", fmt.Sprintf(format, args...))
}
// Err logs an ERR level message
func (l *LogBuffer) Err(format string, args ...any) {
l.Log("ERR", fmt.Sprintf(format, args...))
}
// Entries returns a copy of all log entries
func (l *LogBuffer) Entries() []LogEntry {
l.mu.RLock()
defer l.mu.RUnlock()
result := make([]LogEntry, len(l.entries))
copy(result, l.entries)
return result
}
// Len returns the number of entries
func (l *LogBuffer) Len() int {
l.mu.RLock()
defer l.mu.RUnlock()
return len(l.entries)
}

125
internal/tui/model.go Normal file
View File

@@ -0,0 +1,125 @@
package tui
import (
"ble_simulator/internal/device"
)
// ControlType represents the type of control
type ControlType int
const (
ControlButton ControlType = iota
ControlSlider
)
// Control represents an interactive control in the TUI
type Control struct {
Name string
Type ControlType
Action func() // For buttons
GetValue func() int // For sliders
SetValue func(int) // For sliders
Min int // For sliders
Max int // For sliders
Step int // For sliders
}
// FocusPanel represents which panel is focused
type FocusPanel int
const (
PanelControls FocusPanel = iota
PanelLogs
)
// Model is the main Bubble Tea model
type Model struct {
state *device.DeviceState
logBuffer *LogBuffer
notifyCh chan string // Channel to send alarm notifications
controls []Control
focusedCtrl int
focusedPanel FocusPanel
logOffset int // Scroll offset for log view
width int
height int
quitting bool
}
// NewModel creates a new TUI model
func NewModel(state *device.DeviceState, logBuffer *LogBuffer, notifyCh chan string) Model {
m := Model{
state: state,
logBuffer: logBuffer,
notifyCh: notifyCh,
focusedCtrl: 0,
focusedPanel: PanelControls,
logOffset: 0,
width: 80,
height: 24,
}
// Setup controls
m.controls = []Control{
{
Name: "Trigger Alarm",
Type: ControlButton,
Action: func() {
state.TriggerAlarm("TRIGGERED")
logBuffer.Info("Alarm triggered manually")
if notifyCh != nil {
select {
case notifyCh <- "ALARM: TRIGGERED":
default:
}
}
},
},
{
Name: "Trigger Tamper",
Type: ControlButton,
Action: func() {
state.TriggerAlarm("TAMPER")
logBuffer.Info("Tamper alarm triggered manually")
if notifyCh != nil {
select {
case notifyCh <- "ALARM: TAMPER":
default:
}
}
},
},
{
Name: "Reset Alarm",
Type: ControlButton,
Action: func() {
state.ResetAlarm()
logBuffer.Info("Alarm reset")
},
},
{
Name: "Sensor Value",
Type: ControlSlider,
GetValue: state.GetSensorValue,
SetValue: state.SetSensorValue,
Min: 0,
Max: 4095,
Step: 50,
},
{
Name: "Jitter Range",
Type: ControlSlider,
GetValue: state.GetJitterRange,
SetValue: state.SetJitterRange,
Min: 0,
Max: 100,
Step: 5,
},
}
return m
}

151
internal/tui/update.go Normal file
View File

@@ -0,0 +1,151 @@
package tui
import (
"time"
tea "github.com/charmbracelet/bubbletea"
)
// tickMsg is sent periodically to refresh the TUI
type tickMsg time.Time
// tickCmd returns a command that sends a tickMsg after 100ms
func tickCmd() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
// Init initializes the model and starts the tick loop
func (m Model) Init() tea.Cmd {
return tickCmd()
}
// Update handles messages and updates the model
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tickMsg:
// Tick fired, refresh and continue ticking
return m, tickCmd()
case tea.KeyMsg:
return m.handleKeypress(msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
}
return m, nil
}
// handleKeypress handles keyboard input
func (m Model) handleKeypress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "ctrl+c":
m.quitting = true
return m, tea.Quit
case "tab":
// Switch focus between panels
if m.focusedPanel == PanelControls {
m.focusedPanel = PanelLogs
} else {
m.focusedPanel = PanelControls
}
return m, nil
case "up", "k":
if m.focusedPanel == PanelControls {
if m.focusedCtrl > 0 {
m.focusedCtrl--
}
} else {
// Scroll logs up
maxOffset := max(m.logBuffer.Len()-(m.height-10), 0)
if m.logOffset < maxOffset {
m.logOffset++
}
}
return m, nil
case "down", "j":
if m.focusedPanel == PanelControls {
if m.focusedCtrl < len(m.controls)-1 {
m.focusedCtrl++
}
} else {
// Scroll logs down
if m.logOffset > 0 {
m.logOffset--
}
}
return m, nil
case "left", "h":
if m.focusedPanel == PanelControls {
ctrl := &m.controls[m.focusedCtrl]
if ctrl.Type == ControlSlider {
value := ctrl.GetValue()
newValue := max(value-ctrl.Step, ctrl.Min)
ctrl.SetValue(newValue)
}
}
return m, nil
case "right", "l":
if m.focusedPanel == PanelControls {
ctrl := &m.controls[m.focusedCtrl]
if ctrl.Type == ControlSlider {
value := ctrl.GetValue()
newValue := min(value+ctrl.Step, ctrl.Max)
ctrl.SetValue(newValue)
}
}
return m, nil
case "enter", " ":
if m.focusedPanel == PanelControls {
ctrl := &m.controls[m.focusedCtrl]
if ctrl.Type == ControlButton && ctrl.Action != nil {
ctrl.Action()
}
}
return m, nil
case "home":
// Jump to top of logs
if m.focusedPanel == PanelLogs {
maxOffset := m.logBuffer.Len() - (m.height - 10)
if maxOffset > 0 {
m.logOffset = maxOffset
}
}
return m, nil
case "end":
// Jump to bottom of logs
if m.focusedPanel == PanelLogs {
m.logOffset = 0
}
return m, nil
case "pgup":
if m.focusedPanel == PanelLogs {
pageSize := m.height - 10
maxOffset := max(m.logBuffer.Len()-pageSize, 0)
m.logOffset = min(m.logOffset+pageSize, maxOffset)
}
return m, nil
case "pgdown":
if m.focusedPanel == PanelLogs {
pageSize := m.height - 10
m.logOffset = max(m.logOffset-pageSize, 0)
}
return m, nil
}
return m, nil
}

290
internal/tui/view.go Normal file
View File

@@ -0,0 +1,290 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// Styles for the TUI
var (
// Colors
primaryColor = lipgloss.Color("39") // Light blue
secondaryColor = lipgloss.Color("245") // Gray
accentColor = lipgloss.Color("205") // Pink
successColor = lipgloss.Color("46") // Green
warningColor = lipgloss.Color("226") // Yellow
errorColor = lipgloss.Color("196") // Red
// Panel styles
panelStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(secondaryColor).
Padding(0, 1)
activePanelStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(primaryColor).
Padding(0, 1)
// Title styles
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(primaryColor).
MarginBottom(1)
// Button styles
buttonStyle = lipgloss.NewStyle().
Padding(0, 2).
Background(secondaryColor).
Foreground(lipgloss.Color("0"))
activeButtonStyle = lipgloss.NewStyle().
Padding(0, 2).
Background(primaryColor).
Foreground(lipgloss.Color("0")).
Bold(true)
// Slider styles
sliderLabelStyle = lipgloss.NewStyle().
Width(14)
sliderValueStyle = lipgloss.NewStyle().
Width(6).
Align(lipgloss.Right)
// Log level styles
logLevelStyles = map[string]lipgloss.Style{
"INFO": lipgloss.NewStyle().Foreground(successColor),
"CONN": lipgloss.NewStyle().Foreground(primaryColor),
"TX": lipgloss.NewStyle().Foreground(accentColor),
"RX": lipgloss.NewStyle().Foreground(warningColor),
"ERR": lipgloss.NewStyle().Foreground(errorColor),
}
// Status styles
statusOnStyle = lipgloss.NewStyle().
Foreground(successColor).
Bold(true)
statusOffStyle = lipgloss.NewStyle().
Foreground(secondaryColor)
// Help style
helpStyle = lipgloss.NewStyle().
Foreground(secondaryColor).
Italic(true)
)
// View renders the TUI
func (m Model) View() string {
if m.quitting {
return "Goodbye!\n"
}
// Calculate panel widths
leftWidth := m.width/2 - 2
rightWidth := m.width - leftWidth - 6
// Render panels
leftPanel := m.renderControlsPanel(leftWidth)
rightPanel := m.renderLogsPanel(rightWidth)
// Apply panel styles based on focus
var leftStyled, rightStyled string
if m.focusedPanel == PanelControls {
leftStyled = activePanelStyle.Width(leftWidth).Render(leftPanel)
rightStyled = panelStyle.Width(rightWidth).Render(rightPanel)
} else {
leftStyled = panelStyle.Width(leftWidth).Render(leftPanel)
rightStyled = activePanelStyle.Width(rightWidth).Render(rightPanel)
}
// Join panels horizontally
content := lipgloss.JoinHorizontal(lipgloss.Top, leftStyled, rightStyled)
// Add title and help
title := titleStyle.Render("BLE Simulator TUI")
help := helpStyle.Render("↑↓: Navigate ←→: Adjust Enter: Activate Tab: Switch Panel q: Quit")
return lipgloss.JoinVertical(lipgloss.Left, title, content, help)
}
// renderControlsPanel renders the left controls panel
func (m Model) renderControlsPanel(width int) string {
var b strings.Builder
b.WriteString(titleStyle.Render("Controls"))
b.WriteString("\n\n")
// Render interactive controls
for i, ctrl := range m.controls {
isActive := m.focusedPanel == PanelControls && i == m.focusedCtrl
switch ctrl.Type {
case ControlButton:
if isActive {
b.WriteString(activeButtonStyle.Render(ctrl.Name))
} else {
b.WriteString(buttonStyle.Render(ctrl.Name))
}
b.WriteString("\n")
case ControlSlider:
b.WriteString(m.renderSlider(ctrl, isActive, width-4))
b.WriteString("\n")
}
b.WriteString("\n")
}
// Parameter display section
b.WriteString("\n")
b.WriteString(titleStyle.Render("Parameters (R/O)"))
b.WriteString("\n\n")
params := m.state.GetAllParams()
paramOrder := []int{1, 2, 3, 4, 11, 12, 20}
paramNames := map[int]string{
1: "W1 (Min)",
2: "W2 (Max)",
3: "W3 (Pulses)",
4: "W4 (Time)",
11: "W11 (MinPD)",
12: "W12 (MaxPD)",
20: "W20 (Gain)",
}
paramRanges := map[int][2]int{
1: {0, 2000},
2: {0, 4095},
3: {1, 50},
4: {10, 1000},
11: {0, 1000},
12: {0, 1000},
20: {0, 255},
}
for _, id := range paramOrder {
val := params[id]
rng := paramRanges[id]
name := paramNames[id]
bar := m.renderProgressBar(val, rng[0], rng[1], 10)
b.WriteString(fmt.Sprintf("%s %s %d\n", sliderLabelStyle.Render(name), bar, val))
}
// Status section
b.WriteString("\n")
b.WriteString(titleStyle.Render("Status"))
b.WriteString("\n\n")
// Connection status
connCount := m.state.GetConnectedClients()
if connCount > 0 {
b.WriteString(fmt.Sprintf("Connected: %s\n", statusOnStyle.Render(fmt.Sprintf("Yes (%d)", connCount))))
} else {
b.WriteString(fmt.Sprintf("Connected: %s\n", statusOffStyle.Render("No")))
}
// Alarm status
if alarmActive, alarmType := m.state.GetAlarmState(); alarmActive {
b.WriteString(fmt.Sprintf("Alarm: %s\n", statusOnStyle.Render(alarmType)))
} else {
b.WriteString(fmt.Sprintf("Alarm: %s\n", statusOffStyle.Render("OFF")))
}
// Prog mode status
if m.state.GetProgMode() {
b.WriteString(fmt.Sprintf("Prog Mode: %s\n", statusOnStyle.Render("ON")))
} else {
b.WriteString(fmt.Sprintf("Prog Mode: %s\n", statusOffStyle.Render("OFF")))
}
return b.String()
}
// renderSlider renders a slider control
func (m Model) renderSlider(ctrl Control, isActive bool, _ int) string {
value := ctrl.GetValue()
bar := m.renderProgressBar(value, ctrl.Min, ctrl.Max, 15)
label := sliderLabelStyle.Render(ctrl.Name)
valueStr := sliderValueStyle.Render(fmt.Sprintf("%d", value))
var prefix string
if isActive {
prefix = lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render("> ")
} else {
prefix = " "
}
return fmt.Sprintf("%s%s %s %s", prefix, label, bar, valueStr)
}
// renderProgressBar renders a progress bar
func (m Model) renderProgressBar(value, min, max, barWidth int) string {
if max <= min {
return strings.Repeat("░", barWidth)
}
ratio := float64(value-min) / float64(max-min)
filled := int(ratio * float64(barWidth))
if filled > barWidth {
filled = barWidth
}
if filled < 0 {
filled = 0
}
filledStyle := lipgloss.NewStyle().Foreground(primaryColor)
emptyStyle := lipgloss.NewStyle().Foreground(secondaryColor)
return filledStyle.Render(strings.Repeat("█", filled)) +
emptyStyle.Render(strings.Repeat("░", barWidth-filled))
}
// renderLogsPanel renders the right logs panel
func (m Model) renderLogsPanel(width int) string {
var b strings.Builder
b.WriteString(titleStyle.Render("Logs"))
b.WriteString("\n\n")
entries := m.logBuffer.Entries()
maxVisible := m.height - 10 // Account for borders, title, help
maxVisible = max(maxVisible, 1)
// Calculate which entries to show
start := max(len(entries)-maxVisible-m.logOffset, 0)
end := min(start+maxVisible, len(entries))
for i := start; i < end; i++ {
entry := entries[i]
// Format log entry
levelStyle, ok := logLevelStyles[entry.Level]
if !ok {
levelStyle = lipgloss.NewStyle()
}
timeStr := entry.Time.Format("15:04:05")
levelStr := levelStyle.Render(fmt.Sprintf("[%s]", entry.Level))
// Truncate message if too long
msg := entry.Message
maxMsgLen := max(width-16, 10)
if len(msg) > maxMsgLen {
msg = msg[:maxMsgLen-3] + "..."
}
b.WriteString(fmt.Sprintf("%s %s %s\n", timeStr, levelStr, msg))
}
// Show scroll indicator if needed
if len(entries) > maxVisible {
indicator := fmt.Sprintf("\n[%d/%d entries]", end, len(entries))
b.WriteString(helpStyle.Render(indicator))
}
return b.String()
}