tui simulation
This commit is contained in:
@@ -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},
|
||||
}))
|
||||
|
||||
must("start advertising", adv.Start())
|
||||
|
||||
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...")
|
||||
|
||||
// Start notification loop (500ms heartbeat with sensor data)
|
||||
go notificationLoop(notifyChar, state)
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}); err != nil {
|
||||
log.Fatalf("Failed to configure advertising: %v", err)
|
||||
}
|
||||
|
||||
func notificationLoop(char *bluetooth.Characteristic, state *device.DeviceState) {
|
||||
if err := adv.Start(); err != nil {
|
||||
log.Fatalf("Failed to start advertising: %v", err)
|
||||
}
|
||||
|
||||
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, logBuffer, notifyCh)
|
||||
|
||||
// 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, logger *tui.LogBuffer, notifyCh chan string) {
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
for range ticker.C {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Send regular sensor data
|
||||
value := state.GetSensorValue()
|
||||
// Add small jitter for realism (-10 to +10)
|
||||
jitter := rand.Intn(21) - 10
|
||||
msg := fmt.Sprintf("SENSOR:%d", value+jitter)
|
||||
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
|
||||
}
|
||||
log.Printf("[TX] Sending: %s", msg)
|
||||
}
|
||||
}
|
||||
logger.TX("Sending: %s", msg)
|
||||
|
||||
func must(action string, err error) {
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to %s: %v", action, err)
|
||||
case alarmMsg := <-notifyCh:
|
||||
// Handle alarm notifications from TUI
|
||||
_, err := char.Write([]byte(alarmMsg + "\n"))
|
||||
if err == nil {
|
||||
logger.TX("Sending: %s", alarmMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
81
docs.md
Normal file
81
docs.md
Normal 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
28
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
|
||||
)
|
||||
|
||||
54
go.sum
54
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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
// Non-blocking write in goroutine to avoid blocking the BLE stack
|
||||
go func() {
|
||||
_, err := notifyChar.Write([]byte(response + "\n"))
|
||||
if err != nil {
|
||||
log.Printf("[ERR] Failed to send response: %v", err)
|
||||
logger.Err("Failed to send response: %v", err)
|
||||
}
|
||||
}()
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
}
|
||||
|
||||
89
internal/tui/logger.go
Normal file
89
internal/tui/logger.go
Normal 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
125
internal/tui/model.go
Normal 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
151
internal/tui/update.go
Normal 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
290
internal/tui/view.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user