diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go index e852f4d..94968e8 100644 --- a/cmd/simulator/main.go +++ b/cmd/simulator/main.go @@ -4,76 +4,118 @@ import ( "fmt" "log" "math/rand" + "os" "time" "ble_simulator/internal/ble" "ble_simulator/internal/device" + "ble_simulator/internal/tui" + tea "github.com/charmbracelet/bubbletea" "tinygo.org/x/bluetooth" ) func main() { - log.Println("[INFO] BLE Simulator v1.0") + // Create shared state and log buffer + state := device.NewDeviceState() + logBuffer := tui.NewLogBuffer(500) + + logBuffer.Info("BLE Simulator v1.0") adapter := bluetooth.DefaultAdapter - must("enable BLE", adapter.Enable()) + if err := adapter.Enable(); err != nil { + log.Fatalf("Failed to enable BLE: %v", err) + } + + // Channel for alarm notifications from TUI + notifyCh := make(chan string, 10) // Connection handler adapter.SetConnectHandler(func(dev bluetooth.Device, connected bool) { if connected { - log.Printf("[CONN] Client Connected: %s", dev.Address.String()) + state.IncrConnectedClients() + logBuffer.Conn("Client Connected: %s", dev.Address.String()) } else { - log.Println("[CONN] Client Disconnected") + state.DecrConnectedClients() + logBuffer.Conn("Client Disconnected") } }) - // Setup device state and service - state := device.NewDeviceState() - notifyChar, err := ble.SetupService(adapter, state) - must("add service", err) + // Setup BLE service with logger + notifyChar, err := ble.SetupService(adapter, state, logBuffer) + if err != nil { + log.Fatalf("Failed to add service: %v", err) + } // Configure advertising adv := adapter.DefaultAdvertisement() - must("configure adv", adv.Configure(bluetooth.AdvertisementOptions{ + if err := adv.Configure(bluetooth.AdvertisementOptions{ LocalName: "GO_SIMULATOR", ServiceUUIDs: []bluetooth.UUID{ble.ServiceUUID}, - })) + }); err != nil { + log.Fatalf("Failed to configure advertising: %v", err) + } - must("start advertising", adv.Start()) + if err := adv.Start(); err != nil { + log.Fatalf("Failed to start advertising: %v", err) + } - log.Println("[INFO] Advertising as GO_SIMULATOR...") - log.Println("[INFO] Using Nordic UART Service (NUS) UUIDs") - log.Println("[INFO] Service UUID: 6e400001-b5a3-f393-e0a9-e50e24dcca9e") - log.Println("[INFO] Command Char: 6e400002-b5a3-f393-e0a9-e50e24dcca9e (Write/RX)") - log.Println("[INFO] Notify Char: 6e400003-b5a3-f393-e0a9-e50e24dcca9e (Notify/TX)") - log.Println("[INFO] Waiting for connections...") + logBuffer.Info("Advertising as GO_SIMULATOR...") + logBuffer.Info("Using Nordic UART Service (NUS) UUIDs") + logBuffer.Info("Service UUID: 6e400001-b5a3-f393-e0a9-e50e24dcca9e") + logBuffer.Info("Command Char: 6e400002-b5a3-f393-e0a9-e50e24dcca9e (Write/RX)") + logBuffer.Info("Notify Char: 6e400003-b5a3-f393-e0a9-e50e24dcca9e (Notify/TX)") + logBuffer.Info("Waiting for connections...") // Start notification loop (500ms heartbeat with sensor data) - go notificationLoop(notifyChar, state) + go notificationLoop(notifyChar, state, logBuffer, notifyCh) - // Block forever - select {} + // Create and run TUI + model := tui.NewModel(state, logBuffer, notifyCh) + p := tea.NewProgram(model, tea.WithAltScreen()) + + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err) + os.Exit(1) + } } -func notificationLoop(char *bluetooth.Characteristic, state *device.DeviceState) { +func notificationLoop(char *bluetooth.Characteristic, state *device.DeviceState, logger *tui.LogBuffer, notifyCh chan string) { ticker := time.NewTicker(500 * time.Millisecond) - for range ticker.C { - value := state.GetSensorValue() - // Add small jitter for realism (-10 to +10) - jitter := rand.Intn(21) - 10 - msg := fmt.Sprintf("SENSOR:%d", value+jitter) + for { + select { + case <-ticker.C: + // Check for alarm state and send alarm message if active + if alarmActive, alarmType := state.GetAlarmState(); alarmActive { + msg := fmt.Sprintf("ALARM: %s", alarmType) + _, err := char.Write([]byte(msg + "\n")) + if err == nil { + logger.TX("Sending: %s", msg) + } + } - _, err := char.Write([]byte(msg + "\n")) - if err != nil { - // Silently ignore write errors (no subscribers) - continue + // Send regular sensor data + value := state.GetSensorValue() + jitter := state.GetJitterRange() + jitterVal := 0 + if jitter > 0 { + jitterVal = rand.Intn(jitter*2+1) - jitter + } + msg := fmt.Sprintf("SENSOR:%d", value+jitterVal) + + _, err := char.Write([]byte(msg + "\n")) + if err != nil { + // Silently ignore write errors (no subscribers) + continue + } + logger.TX("Sending: %s", msg) + + case alarmMsg := <-notifyCh: + // Handle alarm notifications from TUI + _, err := char.Write([]byte(alarmMsg + "\n")) + if err == nil { + logger.TX("Sending: %s", alarmMsg) + } } - log.Printf("[TX] Sending: %s", msg) - } -} - -func must(action string, err error) { - if err != nil { - log.Fatalf("Failed to %s: %v", action, err) } } diff --git a/docs.md b/docs.md new file mode 100644 index 0000000..c35e4de --- /dev/null +++ b/docs.md @@ -0,0 +1,81 @@ +# Manuale Protocollo Comandi V-BLACK (nRF52) + +**Data:** 19/01/2026 +**Comunicazione:** Nordic UART Service (NUS) + +## 1. Introduzione + +Il dispositivo comunica tramite il servizio seriale standard Nordic UART. + +- **Caratteristica RX (Scrittura):** Canale per inviare i comandi alla scheda. +- **Caratteristica TX (Notifica):** Canale per ricevere dati e risposte dalla scheda. + +> **NOTA IMPORTANTE:** Per modificare (W) o leggere (R) i parametri, è OBBLIGATORIO entrare prima in modalità programmazione inviando il comando `PROG_ON`. + +--- + +## 2. Comandi di Controllo Globali + +Possono essere inviati in qualsiasi momento. Sono case-insensitive. + +| Comando | Descrizione | Risposta Attesa | +| :--------- | :------------------------------------------------------------- | :------------------------------------- | +| `PROG_ON` | Abilita Modalità Programmazione. Ferma l'allarme. | `PROG_MODE: ON` | +| `PROG_OFF` | Esce dalla Modalità Programmazione, salva e riabilita allarme. | `PROG_MODE: OFF` | +| `RESET` | Spegne uscita allarme e azzera i contatori. | `INFO: Uscita allarme resettata (BT).` | +| `CALI` | Attiva/Disattiva modalità diagnostica (Dati RAW veloci). | `--- MODALITA CALIBRAZIONE ATTIVA ---` | +| `FACTORY` | Ripristino ai dati di fabbrica (Default). | `FACTORY RESET DONE` | + +--- + +## 3. Configurazione Parametri (Scrittura 'W') + +**Requisito:** Modalità Programmazione ATTIVA. +**Formato:** `W=` (Esempio: `W1=1200`) + +| ID | Nome | Range | Descrizione | +| :------ | :----------------- | :-------- | :------------------------------------------------------------------------- | +| **W1** | Soglia Minima | 0 - 2000 | Livello minimo vibrazione per urto. (100-500: Sensibile, 1000-1500: Medio) | +| **W2** | Soglia Massima | 0 - 4095 | Filtro superiore disturbi. Default: 4000. | +| **W3** | Conteggio Impulsi | 1 - 50 | Numero di impulsi W1 necessari per allarme. Default: 2 o 3. | +| **W4** | Finestra Tempo | 10 - 1000 | Ciclo di analisi in ms per impulsi W3. Default: 40. | +| **W20** | Gain (Wiper) | 0 - 255 | Potenziometro digitale (0: max, 255: min). | +| **W11** | Durata Min Impulso | 0 - 1000 | Parametro avanzato/riservato. | +| **W12** | Durata Max Impulso | 0 - 1000 | Parametro avanzato/riservato. | + +**Risposta tipica:** `OK: Parametro W1 impostato e salvato: 1200` + +--- + +## 4. Lettura Parametri (Lettura 'R') + +**Formato:** `R` (Esempio: `R1`) + +| Comando | Risposta Ricevuta | Significato | +| :------ | :---------------- | :-------------------------------- | +| `R1` | `PARAM: W1=1200` | Restituisce valore Soglia Minima | +| `R2` | `PARAM: W2=4000` | Restituisce valore Soglia Massima | +| ... | ... | ... | + +--- + +## 5. Messaggi dal Dispositivo (Notifiche TX) + +### A. Streaming Dati (Heartbeat) + +Inviato ogni 500 ms. + +- **Formato:** `SENSOR:` (Esempio: `SENSOR:2067`) +- **Nota:** Indica valore ADC a riposo (ideale ~2067). + +### B. Eventi di Allarme + +- `ALARM: TRIGGERED`: Vibrazione valida rilevata (Soglia W1 per W3 volte). +- `ALARM: TAMPER`: Taglio cavi o corto circuito (Segnale fisso 0 o 4095). +- `INFO: Allarme resettato automaticamente`: Fine tempo sirena (5s). + +### C. Errori Comuni + +- `ERRORE: I comandi 'W' sono accettati solo in modalità programmazione.` +- `ERRORE: ID Sconosciuto` +- `ERRORE: Formato comando non valido` diff --git a/go.mod b/go.mod index 41eb2f6..03d2e1b 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,39 @@ module ble_simulator go 1.25.6 require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + tinygo.org/x/bluetooth v0.14.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.5 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.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.19 // 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/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af // indirect github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 // indirect github.com/tinygo-org/cbgo v0.0.4 // indirect github.com/tinygo-org/pio v0.2.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect - golang.org/x/sys v0.11.0 // indirect - tinygo.org/x/bluetooth v0.14.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index f621139..85b4e56 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,51 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +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.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4= +github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b h1:du3zG5fd8snsFN6RBoLA7fpaYV9ZQIsyH9snlk2Zvik= github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA= github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= @@ -18,18 +58,28 @@ github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710/go.mod h1:oCVCNGCHMKoB github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q= +github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/tinygo-org/cbgo v0.0.4 h1:3D76CRYbH03Rudi8sEgs/YO0x3JIMdyq8jlQtk/44fU= github.com/tinygo-org/cbgo v0.0.4/go.mod h1:7+HgWIHd4nbAz0ESjGlJ1/v9LDU1Ox8MGzP9mah/fLk= github.com/tinygo-org/pio v0.2.0 h1:vo3xa6xDZ2rVtxrks/KcTZHF3qq4lyWOntvEvl2pOhU= github.com/tinygo-org/pio v0.2.0/go.mod h1:LU7Dw00NJ+N86QkeTGjMLNkYcEYMor6wTDpTCu0EaH8= +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-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0= golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= tinygo.org/x/bluetooth v0.14.0 h1:rrUaT+Fu6O0phGm4Y5UZULL8F7UahOq/JwGAPjJm+V4= tinygo.org/x/bluetooth v0.14.0/go.mod h1:YnyJRVX09i+wkFeHpXut0b+qHq+T2WwKBRRiF/scANA= diff --git a/internal/ble/service.go b/internal/ble/service.go index 6b0ebac..a488d58 100644 --- a/internal/ble/service.go +++ b/internal/ble/service.go @@ -2,13 +2,13 @@ package ble import ( "ble_simulator/internal/device" - "log" + "ble_simulator/internal/tui" "tinygo.org/x/bluetooth" ) // SetupService configures the GATT service with command and notify characteristics -func SetupService(adapter *bluetooth.Adapter, state *device.DeviceState) (*bluetooth.Characteristic, error) { +func SetupService(adapter *bluetooth.Adapter, state *device.DeviceState, logger *tui.LogBuffer) (*bluetooth.Characteristic, error) { var notifyChar bluetooth.Characteristic err := adapter.AddService(&bluetooth.Service{ @@ -20,15 +20,18 @@ func SetupService(adapter *bluetooth.Adapter, state *device.DeviceState) (*bluet bluetooth.CharacteristicWriteWithoutResponsePermission, WriteEvent: func(client bluetooth.Connection, offset int, value []byte) { cmd := string(value) - log.Printf("[RX] Command: %s", cmd) + logger.RX("Command: %s", cmd) response := state.ProcessCommand(cmd) - log.Printf("[TX] Response: %s", response) + logger.TX("Response: %s", response) - _, err := notifyChar.Write([]byte(response + "\n")) - if err != nil { - log.Printf("[ERR] Failed to send response: %v", err) - } + // Non-blocking write in goroutine to avoid blocking the BLE stack + go func() { + _, err := notifyChar.Write([]byte(response + "\n")) + if err != nil { + logger.Err("Failed to send response: %v", err) + } + }() }, }, { diff --git a/internal/device/state.go b/internal/device/state.go index b1a0b8f..64be144 100644 --- a/internal/device/state.go +++ b/internal/device/state.go @@ -1,6 +1,9 @@ package device -import "sync" +import ( + "maps" + "sync" +) // DeviceState holds the simulated device state with thread-safe access type DeviceState struct { @@ -9,6 +12,7 @@ type DeviceState struct { progMode bool caliMode bool alarmActive bool + alarmType string // "TRIGGERED" or "TAMPER" // Parameters from docs.toon // W1=1200 (Min Threshold), W2=4000 (Max Threshold), W3=2 (Pulse Count), @@ -16,6 +20,9 @@ type DeviceState struct { params map[int]int sensorValue int // Default 2067 (ideal rest value) + jitterRange int // Default 10 (means ±10) + + connectedClients int // Track number of connected BLE clients } // NewDeviceState creates a new DeviceState with default values @@ -24,16 +31,19 @@ func NewDeviceState() *DeviceState { progMode: false, caliMode: false, alarmActive: false, + alarmType: "", params: map[int]int{ - 1: 1200, // W1 - Min Threshold - 2: 4000, // W2 - Max Threshold - 3: 2, // W3 - Pulse Count - 4: 40, // W4 - Time Window - 11: 0, // W11 - Min Pulse Duration (reserved) - 12: 0, // W12 - Max Pulse Duration (reserved) - 20: 128, // W20 - Gain (Wiper) + 1: 1000, // W1 - Min Threshold (middle of 0-2000) + 2: 2047, // W2 - Max Threshold (middle of 0-4095) + 3: 25, // W3 - Pulse Count (middle of 1-50) + 4: 505, // W4 - Time Window (middle of 10-1000) + 11: 0, // W11 - Min Pulse Duration (min) + 12: 1000, // W12 - Max Pulse Duration (max) + 20: 127, // W20 - Gain (middle of 0-255) }, - sensorValue: 2067, + sensorValue: 2067, + jitterRange: 10, + connectedClients: 0, } } @@ -109,14 +119,89 @@ func (s *DeviceState) ResetToDefaults() { s.progMode = false s.caliMode = false s.alarmActive = false + s.alarmType = "" s.params = map[int]int{ - 1: 1200, - 2: 4000, - 3: 2, - 4: 40, + 1: 1000, + 2: 2047, + 3: 25, + 4: 505, 11: 0, - 12: 0, - 20: 128, + 12: 1000, + 20: 127, } s.sensorValue = 2067 + s.jitterRange = 10 +} + +// SetSensorValue sets the current sensor value +func (s *DeviceState) SetSensorValue(value int) { + s.mu.Lock() + defer s.mu.Unlock() + s.sensorValue = value +} + +// GetJitterRange returns the current jitter range +func (s *DeviceState) GetJitterRange() int { + s.mu.RLock() + defer s.mu.RUnlock() + return s.jitterRange +} + +// SetJitterRange sets the jitter range +func (s *DeviceState) SetJitterRange(jitter int) { + s.mu.Lock() + defer s.mu.Unlock() + s.jitterRange = jitter +} + +// TriggerAlarm triggers an alarm with the given type ("TRIGGERED" or "TAMPER") +func (s *DeviceState) TriggerAlarm(alarmType string) { + s.mu.Lock() + defer s.mu.Unlock() + s.alarmActive = true + s.alarmType = alarmType +} + +// GetAlarmType returns the current alarm type +func (s *DeviceState) GetAlarmType() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.alarmType +} + +// GetAlarmState returns both the alarm active state and type atomically +func (s *DeviceState) GetAlarmState() (bool, string) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.alarmActive, s.alarmType +} + +// GetAllParams returns a copy of all parameters +func (s *DeviceState) GetAllParams() map[int]int { + s.mu.RLock() + defer s.mu.RUnlock() + return maps.Clone(s.params) +} + +// IncrConnectedClients increments the connected clients count +func (s *DeviceState) IncrConnectedClients() { + s.mu.Lock() + defer s.mu.Unlock() + s.connectedClients++ +} + +// DecrConnectedClients decrements the connected clients count +func (s *DeviceState) DecrConnectedClients() { + s.mu.Lock() + defer s.mu.Unlock() + if s.connectedClients > 0 { + s.connectedClients-- + } +} + +// GetConnectedClients returns the number of connected clients +func (s *DeviceState) GetConnectedClients() int { + s.mu.RLock() + defer s.mu.RUnlock() + return s.connectedClients } diff --git a/internal/tui/logger.go b/internal/tui/logger.go new file mode 100644 index 0000000..747826a --- /dev/null +++ b/internal/tui/logger.go @@ -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) +} + diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..0b9900c --- /dev/null +++ b/internal/tui/model.go @@ -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 +} diff --git a/internal/tui/update.go b/internal/tui/update.go new file mode 100644 index 0000000..e911264 --- /dev/null +++ b/internal/tui/update.go @@ -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 +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..d82f9ff --- /dev/null +++ b/internal/tui/view.go @@ -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() +}