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

View File

@@ -4,76 +4,118 @@ import (
"fmt" "fmt"
"log" "log"
"math/rand" "math/rand"
"os"
"time" "time"
"ble_simulator/internal/ble" "ble_simulator/internal/ble"
"ble_simulator/internal/device" "ble_simulator/internal/device"
"ble_simulator/internal/tui"
tea "github.com/charmbracelet/bubbletea"
"tinygo.org/x/bluetooth" "tinygo.org/x/bluetooth"
) )
func main() { 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 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 // Connection handler
adapter.SetConnectHandler(func(dev bluetooth.Device, connected bool) { adapter.SetConnectHandler(func(dev bluetooth.Device, connected bool) {
if connected { if connected {
log.Printf("[CONN] Client Connected: %s", dev.Address.String()) state.IncrConnectedClients()
logBuffer.Conn("Client Connected: %s", dev.Address.String())
} else { } else {
log.Println("[CONN] Client Disconnected") state.DecrConnectedClients()
logBuffer.Conn("Client Disconnected")
} }
}) })
// Setup device state and service // Setup BLE service with logger
state := device.NewDeviceState() notifyChar, err := ble.SetupService(adapter, state, logBuffer)
notifyChar, err := ble.SetupService(adapter, state) if err != nil {
must("add service", err) log.Fatalf("Failed to add service: %v", err)
}
// Configure advertising // Configure advertising
adv := adapter.DefaultAdvertisement() adv := adapter.DefaultAdvertisement()
must("configure adv", adv.Configure(bluetooth.AdvertisementOptions{ if err := adv.Configure(bluetooth.AdvertisementOptions{
LocalName: "GO_SIMULATOR", LocalName: "GO_SIMULATOR",
ServiceUUIDs: []bluetooth.UUID{ble.ServiceUUID}, 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...") logBuffer.Info("Advertising as GO_SIMULATOR...")
log.Println("[INFO] Using Nordic UART Service (NUS) UUIDs") logBuffer.Info("Using Nordic UART Service (NUS) UUIDs")
log.Println("[INFO] Service UUID: 6e400001-b5a3-f393-e0a9-e50e24dcca9e") logBuffer.Info("Service UUID: 6e400001-b5a3-f393-e0a9-e50e24dcca9e")
log.Println("[INFO] Command Char: 6e400002-b5a3-f393-e0a9-e50e24dcca9e (Write/RX)") logBuffer.Info("Command Char: 6e400002-b5a3-f393-e0a9-e50e24dcca9e (Write/RX)")
log.Println("[INFO] Notify Char: 6e400003-b5a3-f393-e0a9-e50e24dcca9e (Notify/TX)") logBuffer.Info("Notify Char: 6e400003-b5a3-f393-e0a9-e50e24dcca9e (Notify/TX)")
log.Println("[INFO] Waiting for connections...") logBuffer.Info("Waiting for connections...")
// Start notification loop (500ms heartbeat with sensor data) // Start notification loop (500ms heartbeat with sensor data)
go notificationLoop(notifyChar, state) go notificationLoop(notifyChar, state, logBuffer, notifyCh)
// Block forever // Create and run TUI
select {} 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) ticker := time.NewTicker(500 * time.Millisecond)
for range ticker.C { for {
value := state.GetSensorValue() select {
// Add small jitter for realism (-10 to +10) case <-ticker.C:
jitter := rand.Intn(21) - 10 // Check for alarm state and send alarm message if active
msg := fmt.Sprintf("SENSOR:%d", value+jitter) 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")) // Send regular sensor data
if err != nil { value := state.GetSensorValue()
// Silently ignore write errors (no subscribers) jitter := state.GetJitterRange()
continue 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)
} }
} }

81
docs.md Normal file
View File

@@ -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<ID>=<VALORE>` (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<ID>` (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:<valore>` (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`

28
go.mod
View File

@@ -3,15 +3,39 @@ module ble_simulator
go 1.25.6 go 1.25.6
require ( 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/go-ole/go-ole v1.2.6 // indirect
github.com/godbus/dbus/v5 v5.1.0 // 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/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b // indirect
github.com/sirupsen/logrus v1.9.3 // indirect github.com/sirupsen/logrus v1.9.3 // indirect
github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af // indirect github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af // indirect
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 // indirect github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 // indirect
github.com/tinygo-org/cbgo v0.0.4 // indirect github.com/tinygo-org/cbgo v0.0.4 // indirect
github.com/tinygo-org/pio v0.2.0 // 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/exp v0.0.0-20241204233417-43b7b7cde48d // indirect
golang.org/x/sys v0.11.0 // indirect golang.org/x/sys v0.38.0 // indirect
tinygo.org/x/bluetooth v0.14.0 // indirect golang.org/x/text v0.3.8 // indirect
) )

54
go.sum
View File

@@ -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.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/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 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 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 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 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/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/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 h1:du3zG5fd8snsFN6RBoLA7fpaYV9ZQIsyH9snlk2Zvik=
github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA= 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= 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/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.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.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 h1:3D76CRYbH03Rudi8sEgs/YO0x3JIMdyq8jlQtk/44fU=
github.com/tinygo-org/cbgo v0.0.4/go.mod h1:7+HgWIHd4nbAz0ESjGlJ1/v9LDU1Ox8MGzP9mah/fLk= 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 h1:vo3xa6xDZ2rVtxrks/KcTZHF3qq4lyWOntvEvl2pOhU=
github.com/tinygo-org/pio v0.2.0/go.mod h1:LU7Dw00NJ+N86QkeTGjMLNkYcEYMor6wTDpTCu0EaH8= 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 h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0=
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= 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-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-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.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.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/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.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 h1:rrUaT+Fu6O0phGm4Y5UZULL8F7UahOq/JwGAPjJm+V4=
tinygo.org/x/bluetooth v0.14.0/go.mod h1:YnyJRVX09i+wkFeHpXut0b+qHq+T2WwKBRRiF/scANA= tinygo.org/x/bluetooth v0.14.0/go.mod h1:YnyJRVX09i+wkFeHpXut0b+qHq+T2WwKBRRiF/scANA=

View File

@@ -2,13 +2,13 @@ package ble
import ( import (
"ble_simulator/internal/device" "ble_simulator/internal/device"
"log" "ble_simulator/internal/tui"
"tinygo.org/x/bluetooth" "tinygo.org/x/bluetooth"
) )
// SetupService configures the GATT service with command and notify characteristics // 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 var notifyChar bluetooth.Characteristic
err := adapter.AddService(&bluetooth.Service{ err := adapter.AddService(&bluetooth.Service{
@@ -20,15 +20,18 @@ func SetupService(adapter *bluetooth.Adapter, state *device.DeviceState) (*bluet
bluetooth.CharacteristicWriteWithoutResponsePermission, bluetooth.CharacteristicWriteWithoutResponsePermission,
WriteEvent: func(client bluetooth.Connection, offset int, value []byte) { WriteEvent: func(client bluetooth.Connection, offset int, value []byte) {
cmd := string(value) cmd := string(value)
log.Printf("[RX] Command: %s", cmd) logger.RX("Command: %s", cmd)
response := state.ProcessCommand(cmd) response := state.ProcessCommand(cmd)
log.Printf("[TX] Response: %s", response) logger.TX("Response: %s", response)
_, err := notifyChar.Write([]byte(response + "\n")) // Non-blocking write in goroutine to avoid blocking the BLE stack
if err != nil { go func() {
log.Printf("[ERR] Failed to send response: %v", err) _, err := notifyChar.Write([]byte(response + "\n"))
} if err != nil {
logger.Err("Failed to send response: %v", err)
}
}()
}, },
}, },
{ {

View File

@@ -1,6 +1,9 @@
package device package device
import "sync" import (
"maps"
"sync"
)
// DeviceState holds the simulated device state with thread-safe access // DeviceState holds the simulated device state with thread-safe access
type DeviceState struct { type DeviceState struct {
@@ -9,6 +12,7 @@ type DeviceState struct {
progMode bool progMode bool
caliMode bool caliMode bool
alarmActive bool alarmActive bool
alarmType string // "TRIGGERED" or "TAMPER"
// Parameters from docs.toon // Parameters from docs.toon
// W1=1200 (Min Threshold), W2=4000 (Max Threshold), W3=2 (Pulse Count), // W1=1200 (Min Threshold), W2=4000 (Max Threshold), W3=2 (Pulse Count),
@@ -16,6 +20,9 @@ type DeviceState struct {
params map[int]int params map[int]int
sensorValue int // Default 2067 (ideal rest value) 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 // NewDeviceState creates a new DeviceState with default values
@@ -24,16 +31,19 @@ func NewDeviceState() *DeviceState {
progMode: false, progMode: false,
caliMode: false, caliMode: false,
alarmActive: false, alarmActive: false,
alarmType: "",
params: map[int]int{ params: map[int]int{
1: 1200, // W1 - Min Threshold 1: 1000, // W1 - Min Threshold (middle of 0-2000)
2: 4000, // W2 - Max Threshold 2: 2047, // W2 - Max Threshold (middle of 0-4095)
3: 2, // W3 - Pulse Count 3: 25, // W3 - Pulse Count (middle of 1-50)
4: 40, // W4 - Time Window 4: 505, // W4 - Time Window (middle of 10-1000)
11: 0, // W11 - Min Pulse Duration (reserved) 11: 0, // W11 - Min Pulse Duration (min)
12: 0, // W12 - Max Pulse Duration (reserved) 12: 1000, // W12 - Max Pulse Duration (max)
20: 128, // W20 - Gain (Wiper) 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.progMode = false
s.caliMode = false s.caliMode = false
s.alarmActive = false s.alarmActive = false
s.alarmType = ""
s.params = map[int]int{ s.params = map[int]int{
1: 1200, 1: 1000,
2: 4000, 2: 2047,
3: 2, 3: 25,
4: 40, 4: 505,
11: 0, 11: 0,
12: 0, 12: 1000,
20: 128, 20: 127,
} }
s.sensorValue = 2067 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
} }

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()
}