Compare commits

..

5 Commits

Author SHA1 Message Date
6df2073174 fix W1 range 2026-02-23 01:35:53 +01:00
e115106b2b fix ranges 2026-02-23 01:12:49 +01:00
dd18f74e65 minor fixes 2026-02-23 00:46:58 +01:00
5bbfda2330 update docs 2026-02-23 00:31:32 +01:00
5e448a14e2 update emulator 2026-02-19 11:43:56 +01:00
7 changed files with 132 additions and 45 deletions

View File

@@ -5,6 +5,7 @@ import (
"log" "log"
"math/rand" "math/rand"
"os" "os"
"sync"
"time" "time"
"ble_simulator/internal/ble" "ble_simulator/internal/ble"
@@ -30,15 +31,30 @@ func main() {
// Channel for alarm notifications from TUI // Channel for alarm notifications from TUI
notifyCh := make(chan string, 10) notifyCh := make(chan string, 10)
// Channel for disconnect-all-clients signal
disconnectCh := make(chan struct{}, 1)
// Connection registry for disconnect-on-tamper
connRegistry := make(map[string]bluetooth.Device)
var connMu sync.Mutex
// Connection handler // Connection handler
adapter.SetConnectHandler(func(dev bluetooth.Device, connected bool) { adapter.SetConnectHandler(func(dev bluetooth.Device, connected bool) {
connMu.Lock()
if connected { if connected {
connRegistry[dev.Address.String()] = dev
state.IncrConnectedClients() state.IncrConnectedClients()
logBuffer.Conn("Client Connected: %s", dev.Address.String()) logBuffer.Conn("Client Connected: %s", dev.Address.String())
} else { } else {
delete(connRegistry, dev.Address.String())
state.DecrConnectedClients() state.DecrConnectedClients()
logBuffer.Conn("Client Disconnected") logBuffer.Conn("Client Disconnected")
// Auto-disable prog mode when no clients connected
if state.GetConnectedClients() == 0 {
state.SetProgMode(false)
} }
}
connMu.Unlock()
}) })
// Setup BLE service with logger // Setup BLE service with logger
@@ -67,11 +83,26 @@ func main() {
logBuffer.Info("Notify Char: 6e400003-b5a3-f393-e0a9-e50e24dcca9e (Notify/TX)") logBuffer.Info("Notify Char: 6e400003-b5a3-f393-e0a9-e50e24dcca9e (Notify/TX)")
logBuffer.Info("Waiting for connections...") logBuffer.Info("Waiting for connections...")
// Start disconnect handler goroutine
go func() {
for range disconnectCh {
connMu.Lock()
for addr, dev := range connRegistry {
if err := dev.Disconnect(); err != nil {
logBuffer.Err("Failed to disconnect %s: %v", addr, err)
} else {
logBuffer.Info("Disconnected client: %s", addr)
}
}
connMu.Unlock()
}
}()
// Start notification loop (500ms heartbeat with sensor data) // Start notification loop (500ms heartbeat with sensor data)
go notificationLoop(notifyChar, state, logBuffer, notifyCh) go notificationLoop(notifyChar, state, logBuffer, notifyCh)
// Create and run TUI // Create and run TUI
model := tui.NewModel(state, logBuffer, notifyCh) model := tui.NewModel(state, logBuffer, notifyCh, disconnectCh)
p := tea.NewProgram(model, tea.WithAltScreen()) p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
@@ -88,13 +119,14 @@ func notificationLoop(char *bluetooth.Characteristic, state *device.DeviceState,
// Check for alarm state and send alarm message if active // Check for alarm state and send alarm message if active
if alarmActive, alarmType := state.GetAlarmState(); alarmActive { if alarmActive, alarmType := state.GetAlarmState(); alarmActive {
msg := fmt.Sprintf("ALARM: %s", alarmType) msg := fmt.Sprintf("ALARM: %s", alarmType)
_, err := char.Write([]byte(msg + "\n")) _, err := char.Write([]byte(msg + "\r\n"))
if err == nil { if err == nil {
logger.TX("Sending: %s", msg) logger.TX("Sending: %s", msg)
} }
} }
// Send regular sensor data // Only send sensor data in programming mode
if state.GetProgMode() {
value := state.GetSensorValue() value := state.GetSensorValue()
jitter := state.GetJitterRange() jitter := state.GetJitterRange()
jitterVal := 0 jitterVal := 0
@@ -103,16 +135,17 @@ func notificationLoop(char *bluetooth.Characteristic, state *device.DeviceState,
} }
msg := fmt.Sprintf("SENSOR:%d", value+jitterVal) msg := fmt.Sprintf("SENSOR:%d", value+jitterVal)
_, err := char.Write([]byte(msg + "\n")) _, err := char.Write([]byte(msg + "\r\n"))
if err != nil { if err != nil {
// Silently ignore write errors (no subscribers) // Silently ignore write errors (no subscribers)
continue continue
} }
logger.TX("Sending: %s", msg) logger.TX("Sending: %s", msg)
}
case alarmMsg := <-notifyCh: case alarmMsg := <-notifyCh:
// Handle alarm notifications from TUI // Handle alarm notifications from TUI
_, err := char.Write([]byte(alarmMsg + "\n")) _, err := char.Write([]byte(alarmMsg + "\r\n"))
if err == nil { if err == nil {
logger.TX("Sending: %s", alarmMsg) logger.TX("Sending: %s", alarmMsg)
} }

View File

@@ -42,6 +42,7 @@ Possono essere inviati in qualsiasi momento. Sono case-insensitive.
| **W20** | Gain (Wiper) | 0 - 255 | Potenziometro digitale (0: max, 255: min). | | **W20** | Gain (Wiper) | 0 - 255 | Potenziometro digitale (0: max, 255: min). |
| **W11** | Durata Min Impulso | 0 - 1000 | Parametro avanzato/riservato. | | **W11** | Durata Min Impulso | 0 - 1000 | Parametro avanzato/riservato. |
| **W12** | Durata Max Impulso | 0 - 1000 | Parametro avanzato/riservato. | | **W12** | Durata Max Impulso | 0 - 1000 | Parametro avanzato/riservato. |
| **WNC** | Normalmente Chiuso | 0 o 1 | Configurazione ingresso (1: NC, 0: NA). Default: 1. |
**Risposta tipica:** `OK: Parametro W1 impostato e salvato: 1200` **Risposta tipica:** `OK: Parametro W1 impostato e salvato: 1200`

View File

@@ -27,7 +27,7 @@ func SetupService(adapter *bluetooth.Adapter, state *device.DeviceState, logger
// Non-blocking write in goroutine to avoid blocking the BLE stack // Non-blocking write in goroutine to avoid blocking the BLE stack
go func() { go func() {
_, err := notifyChar.Write([]byte(response + "\n")) _, err := notifyChar.Write([]byte(response + "\r\n"))
if err != nil { if err != nil {
logger.Err("Failed to send response: %v", err) logger.Err("Failed to send response: %v", err)
} }

View File

@@ -56,6 +56,17 @@ func (s *DeviceState) handleWriteCommand(cmd string) string {
return "ERRORE: I comandi 'W' sono accettati solo in modalità programmazione." return "ERRORE: I comandi 'W' sono accettati solo in modalità programmazione."
} }
// Handle special WNC command
if strings.HasPrefix(cmd, "WNC=") {
valueStr := strings.TrimPrefix(cmd, "WNC=")
value, err := strconv.Atoi(valueStr)
if err != nil || (value != 0 && value != 1) {
return "ERRORE: Valore fuori range per WNC"
}
s.SetNCParam(value == 1)
return fmt.Sprintf("OK: Parametro WNC impostato e salvato: %d", value)
}
// Parse command format: "W<id>=<value>" // Parse command format: "W<id>=<value>"
parts := strings.SplitN(cmd, "=", 2) parts := strings.SplitN(cmd, "=", 2)
if len(parts) != 2 { if len(parts) != 2 {
@@ -97,6 +108,15 @@ func (s *DeviceState) handleReadCommand(cmd string) string {
return "ERRORE: I comandi 'W' sono accettati solo in modalità programmazione." return "ERRORE: I comandi 'W' sono accettati solo in modalità programmazione."
} }
// Handle special RNC command
if cmd == "RNC" {
value := 0
if s.GetNCParam() {
value = 1
}
return fmt.Sprintf("PARAM: NC=%d", value)
}
// Extract parameter ID // Extract parameter ID
idStr := strings.TrimPrefix(cmd, "R") idStr := strings.TrimPrefix(cmd, "R")
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
@@ -131,8 +151,8 @@ func isValidParamID(id int) bool {
// isReadableParamID checks if the parameter ID is readable // isReadableParamID checks if the parameter ID is readable
func isReadableParamID(id int) bool { func isReadableParamID(id int) bool {
// According to docs.toon, only R1, R2, R20 are documented // All writable parameters are also readable
readableIDs := []int{1, 2, 20} readableIDs := []int{1, 2, 3, 4, 11, 12, 20}
for _, v := range readableIDs { for _, v := range readableIDs {
if id == v { if id == v {
return true return true
@@ -147,9 +167,9 @@ func validateParamRange(id, value int) bool {
1: {0, 2000}, // W1 - Min Threshold 1: {0, 2000}, // W1 - Min Threshold
2: {0, 4095}, // W2 - Max Threshold 2: {0, 4095}, // W2 - Max Threshold
3: {1, 50}, // W3 - Pulse Count 3: {1, 50}, // W3 - Pulse Count
4: {10, 1000}, // W4 - Time Window 4: {1000, 60000},// W4 - Time Window
11: {0, 1000}, // W11 - Min Pulse Duration 11: {5, 300}, // W11 - Min Pulse Duration
12: {0, 1000}, // W12 - Max Pulse Duration 12: {5, 300}, // W12 - Max Pulse Duration
20: {0, 255}, // W20 - Gain (Wiper) 20: {0, 255}, // W20 - Gain (Wiper)
} }

View File

@@ -19,6 +19,8 @@ type DeviceState struct {
// W4=40 (Time Window), W20=128 (Gain) // W4=40 (Time Window), W20=128 (Gain)
params map[int]int params map[int]int
ncParam bool // NC parameter: true = NO (1), false = NC (0), default is NO
sensorValue int // Default 2067 (ideal rest value) sensorValue int // Default 2067 (ideal rest value)
jitterRange int // Default 10 (means ±10) jitterRange int // Default 10 (means ±10)
@@ -33,14 +35,15 @@ func NewDeviceState() *DeviceState {
alarmActive: false, alarmActive: false,
alarmType: "", alarmType: "",
params: map[int]int{ params: map[int]int{
1: 1000, // W1 - Min Threshold (middle of 0-2000) 1: 1200, // W1 - Min Threshold (default as per docs)
2: 2047, // W2 - Max Threshold (middle of 0-4095) 2: 2047, // W2 - Max Threshold (middle of 0-4095)
3: 25, // W3 - Pulse Count (middle of 1-50) 3: 25, // W3 - Pulse Count (middle of 1-50)
4: 505, // W4 - Time Window (middle of 10-1000) 4: 30500, // W4 - Time Window (middle of 1000-60000)
11: 0, // W11 - Min Pulse Duration (min) 11: 152, // W11 - Min Pulse Duration (middle of 5-300)
12: 1000, // W12 - Max Pulse Duration (max) 12: 152, // W12 - Max Pulse Duration (middle of 5-300)
20: 127, // W20 - Gain (middle of 0-255) 20: 127, // W20 - Gain (middle of 0-255)
}, },
ncParam: true, // Default NO mode (1)
sensorValue: 2067, sensorValue: 2067,
jitterRange: 10, jitterRange: 10,
connectedClients: 0, connectedClients: 0,
@@ -121,14 +124,15 @@ func (s *DeviceState) ResetToDefaults() {
s.alarmActive = false s.alarmActive = false
s.alarmType = "" s.alarmType = ""
s.params = map[int]int{ s.params = map[int]int{
1: 1000, 1: 127,
2: 2047, 2: 2047,
3: 25, 3: 25,
4: 505, 4: 30500,
11: 0, 11: 152,
12: 1000, 12: 152,
20: 127, 20: 127,
} }
s.ncParam = true // Default NO mode (1)
s.sensorValue = 2067 s.sensorValue = 2067
s.jitterRange = 10 s.jitterRange = 10
} }
@@ -176,6 +180,20 @@ func (s *DeviceState) GetAlarmState() (bool, string) {
return s.alarmActive, s.alarmType return s.alarmActive, s.alarmType
} }
// GetNCParam returns the NC parameter value (true = NO, false = NC)
func (s *DeviceState) GetNCParam() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.ncParam
}
// SetNCParam sets the NC parameter value (true = NO, false = NC)
func (s *DeviceState) SetNCParam(value bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.ncParam = value
}
// GetAllParams returns a copy of all parameters // GetAllParams returns a copy of all parameters
func (s *DeviceState) GetAllParams() map[int]int { func (s *DeviceState) GetAllParams() map[int]int {
s.mu.RLock() s.mu.RLock()

View File

@@ -37,6 +37,7 @@ type Model struct {
state *device.DeviceState state *device.DeviceState
logBuffer *LogBuffer logBuffer *LogBuffer
notifyCh chan string // Channel to send alarm notifications notifyCh chan string // Channel to send alarm notifications
disconnectCh chan struct{} // Channel to trigger disconnect all clients
controls []Control controls []Control
focusedCtrl int focusedCtrl int
@@ -51,11 +52,12 @@ type Model struct {
} }
// NewModel creates a new TUI model // NewModel creates a new TUI model
func NewModel(state *device.DeviceState, logBuffer *LogBuffer, notifyCh chan string) Model { func NewModel(state *device.DeviceState, logBuffer *LogBuffer, notifyCh chan string, disconnectCh chan struct{}) Model {
m := Model{ m := Model{
state: state, state: state,
logBuffer: logBuffer, logBuffer: logBuffer,
notifyCh: notifyCh, notifyCh: notifyCh,
disconnectCh: disconnectCh,
focusedCtrl: 0, focusedCtrl: 0,
focusedPanel: PanelControls, focusedPanel: PanelControls,
logOffset: 0, logOffset: 0,
@@ -84,13 +86,19 @@ func NewModel(state *device.DeviceState, logBuffer *LogBuffer, notifyCh chan str
Type: ControlButton, Type: ControlButton,
Action: func() { Action: func() {
state.TriggerAlarm("TAMPER") state.TriggerAlarm("TAMPER")
logBuffer.Info("Tamper alarm triggered manually") logBuffer.Info("Tamper alarm triggered - disconnecting all clients")
if notifyCh != nil { if notifyCh != nil {
select { select {
case notifyCh <- "ALARM: TAMPER": case notifyCh <- "ALARM: TAMPER":
default: default:
} }
} }
if disconnectCh != nil {
select {
case disconnectCh <- struct{}{}:
default:
}
}
}, },
}, },
{ {

View File

@@ -158,9 +158,9 @@ func (m Model) renderControlsPanel(width int) string {
1: {0, 2000}, 1: {0, 2000},
2: {0, 4095}, 2: {0, 4095},
3: {1, 50}, 3: {1, 50},
4: {10, 1000}, 4: {1000, 60000},
11: {0, 1000}, 11: {5, 300},
12: {0, 1000}, 12: {5, 300},
20: {0, 255}, 20: {0, 255},
} }
@@ -172,6 +172,13 @@ func (m Model) renderControlsPanel(width int) string {
b.WriteString(fmt.Sprintf("%s %s %d\n", sliderLabelStyle.Render(name), bar, val)) b.WriteString(fmt.Sprintf("%s %s %d\n", sliderLabelStyle.Render(name), bar, val))
} }
// NC parameter display
ncValue := "True"
if !m.state.GetNCParam() {
ncValue = "False"
}
b.WriteString(fmt.Sprintf("%s %s\n", sliderLabelStyle.Render("WNC (Tru/Fal)"), ncValue))
// Status section // Status section
b.WriteString("\n") b.WriteString("\n")
b.WriteString(titleStyle.Render("Status")) b.WriteString(titleStyle.Render("Status"))