Compare commits
7 Commits
7eb052f20e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6df2073174 | |||
| e115106b2b | |||
| dd18f74e65 | |||
| 5bbfda2330 | |||
| 5e448a14e2 | |||
| 27517bd2ea | |||
| 65fed65bf2 |
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ble_simulator/internal/ble"
|
||||
@@ -30,15 +31,30 @@ func main() {
|
||||
// Channel for alarm notifications from TUI
|
||||
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
|
||||
adapter.SetConnectHandler(func(dev bluetooth.Device, connected bool) {
|
||||
connMu.Lock()
|
||||
if connected {
|
||||
connRegistry[dev.Address.String()] = dev
|
||||
state.IncrConnectedClients()
|
||||
logBuffer.Conn("Client Connected: %s", dev.Address.String())
|
||||
} else {
|
||||
delete(connRegistry, dev.Address.String())
|
||||
state.DecrConnectedClients()
|
||||
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
|
||||
@@ -67,11 +83,26 @@ func main() {
|
||||
logBuffer.Info("Notify Char: 6e400003-b5a3-f393-e0a9-e50e24dcca9e (Notify/TX)")
|
||||
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)
|
||||
go notificationLoop(notifyChar, state, logBuffer, notifyCh)
|
||||
|
||||
// Create and run TUI
|
||||
model := tui.NewModel(state, logBuffer, notifyCh)
|
||||
model := tui.NewModel(state, logBuffer, notifyCh, disconnectCh)
|
||||
p := tea.NewProgram(model, tea.WithAltScreen())
|
||||
|
||||
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
|
||||
if alarmActive, alarmType := state.GetAlarmState(); alarmActive {
|
||||
msg := fmt.Sprintf("ALARM: %s", alarmType)
|
||||
_, err := char.Write([]byte(msg + "\n"))
|
||||
_, err := char.Write([]byte(msg + "\r\n"))
|
||||
if err == nil {
|
||||
logger.TX("Sending: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Send regular sensor data
|
||||
// Only send sensor data in programming mode
|
||||
if state.GetProgMode() {
|
||||
value := state.GetSensorValue()
|
||||
jitter := state.GetJitterRange()
|
||||
jitterVal := 0
|
||||
@@ -103,16 +135,17 @@ func notificationLoop(char *bluetooth.Characteristic, state *device.DeviceState,
|
||||
}
|
||||
msg := fmt.Sprintf("SENSOR:%d", value+jitterVal)
|
||||
|
||||
_, err := char.Write([]byte(msg + "\n"))
|
||||
_, err := char.Write([]byte(msg + "\r\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"))
|
||||
_, err := char.Write([]byte(alarmMsg + "\r\n"))
|
||||
if err == nil {
|
||||
logger.TX("Sending: %s", alarmMsg)
|
||||
}
|
||||
|
||||
1
docs.md
1
docs.md
@@ -42,6 +42,7 @@ Possono essere inviati in qualsiasi momento. Sono case-insensitive.
|
||||
| **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. |
|
||||
| **WNC** | Normalmente Chiuso | 0 o 1 | Configurazione ingresso (1: NC, 0: NA). Default: 1. |
|
||||
|
||||
**Risposta tipica:** `OK: Parametro W1 impostato e salvato: 1200`
|
||||
|
||||
|
||||
2
go.mod
2
go.mod
@@ -5,6 +5,7 @@ go 1.25.6
|
||||
require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/godbus/dbus/v5 v5.1.0
|
||||
tinygo.org/x/bluetooth v0.14.0
|
||||
)
|
||||
|
||||
@@ -19,7 +20,6 @@ require (
|
||||
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
|
||||
|
||||
139
internal/ble/agent.go
Normal file
139
internal/ble/agent.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package ble
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
agentPath = "/org/bluez/agent/noinputnooutput"
|
||||
agentInterface = "org.bluez.Agent1"
|
||||
agentManager = "org.bluez.AgentManager1"
|
||||
bluezService = "org.bluez"
|
||||
bluezPath = "/org/bluez"
|
||||
)
|
||||
|
||||
// NoInputNoOutputAgent implements org.bluez.Agent1 with NoInputNoOutput capability.
|
||||
// This disables pairing prompts and allows "Just Works" pairing.
|
||||
type NoInputNoOutputAgent struct{}
|
||||
|
||||
// Release is called when the agent is unregistered.
|
||||
func (a *NoInputNoOutputAgent) Release() *dbus.Error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequestPinCode returns an error as PIN codes are not supported with NoInputNoOutput.
|
||||
func (a *NoInputNoOutputAgent) RequestPinCode(device dbus.ObjectPath) (string, *dbus.Error) {
|
||||
return "", dbus.NewError("org.bluez.Error.Rejected", []any{"NoInputNoOutput agent"})
|
||||
}
|
||||
|
||||
// DisplayPinCode does nothing as we have no display.
|
||||
func (a *NoInputNoOutputAgent) DisplayPinCode(device dbus.ObjectPath, pincode string) *dbus.Error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequestPasskey returns an error as passkeys are not supported with NoInputNoOutput.
|
||||
func (a *NoInputNoOutputAgent) RequestPasskey(device dbus.ObjectPath) (uint32, *dbus.Error) {
|
||||
return 0, dbus.NewError("org.bluez.Error.Rejected", []any{"NoInputNoOutput agent"})
|
||||
}
|
||||
|
||||
// DisplayPasskey does nothing as we have no display.
|
||||
func (a *NoInputNoOutputAgent) DisplayPasskey(device dbus.ObjectPath, passkey uint32, entered uint16) *dbus.Error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequestConfirmation auto-accepts pairing for "Just Works" mode.
|
||||
func (a *NoInputNoOutputAgent) RequestConfirmation(device dbus.ObjectPath, passkey uint32) *dbus.Error {
|
||||
return nil // Accept pairing silently
|
||||
}
|
||||
|
||||
// RequestAuthorization auto-accepts authorization for "Just Works" mode.
|
||||
func (a *NoInputNoOutputAgent) RequestAuthorization(device dbus.ObjectPath) *dbus.Error {
|
||||
return nil // Accept authorization silently
|
||||
}
|
||||
|
||||
// AuthorizeService auto-accepts service authorization.
|
||||
func (a *NoInputNoOutputAgent) AuthorizeService(device dbus.ObjectPath, uuid string) *dbus.Error {
|
||||
return nil // Accept service authorization silently
|
||||
}
|
||||
|
||||
// Cancel is called when an operation is canceled.
|
||||
func (a *NoInputNoOutputAgent) Cancel() *dbus.Error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterNoInputNoOutputAgent connects to system DBus and registers a NoInputNoOutput
|
||||
// agent with BlueZ. This disables pairing prompts for BLE connections.
|
||||
func RegisterNoInputNoOutputAgent() error {
|
||||
conn, err := dbus.SystemBus()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to system DBus: %w", err)
|
||||
}
|
||||
|
||||
agent := &NoInputNoOutputAgent{}
|
||||
|
||||
// Export the agent object on DBus
|
||||
err = conn.Export(agent, agentPath, agentInterface)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to export agent: %w", err)
|
||||
}
|
||||
|
||||
// Get the AgentManager interface
|
||||
agentMgr := conn.Object(bluezService, bluezPath)
|
||||
|
||||
// Register our agent with NoInputNoOutput capability
|
||||
call := agentMgr.Call(agentManager+".RegisterAgent", 0, dbus.ObjectPath(agentPath), "NoInputNoOutput")
|
||||
if call.Err != nil {
|
||||
return fmt.Errorf("failed to register agent: %w", call.Err)
|
||||
}
|
||||
|
||||
// Request to be the default agent
|
||||
call = agentMgr.Call(agentManager+".RequestDefaultAgent", 0, dbus.ObjectPath(agentPath))
|
||||
if call.Err != nil {
|
||||
return fmt.Errorf("failed to set default agent: %w", call.Err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAdapterNotPairable sets the Pairable property to false on the default adapter.
|
||||
// This is optional and prevents the adapter from initiating pairing.
|
||||
func SetAdapterNotPairable() error {
|
||||
conn, err := dbus.SystemBus()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to system DBus: %w", err)
|
||||
}
|
||||
|
||||
// Get the default adapter (hci0)
|
||||
adapter := conn.Object(bluezService, "/org/bluez/hci0")
|
||||
|
||||
// Set Pairable to false using org.freedesktop.DBus.Properties interface
|
||||
call := adapter.Call("org.freedesktop.DBus.Properties.Set", 0,
|
||||
"org.bluez.Adapter1", "Pairable", dbus.MakeVariant(false))
|
||||
if call.Err != nil {
|
||||
return fmt.Errorf("failed to set Pairable: %w", call.Err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableBonding runs btmgmt to disable bonding on the default adapter.
|
||||
// This prevents phones from initiating pairing requests.
|
||||
func DisableBonding() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "btmgmt", "-i", "0", "bondable", "off")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("btmgmt timed out")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to disable bonding: %w (output: %s)", err, output)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func SetupService(adapter *bluetooth.Adapter, state *device.DeviceState, logger
|
||||
|
||||
// Non-blocking write in goroutine to avoid blocking the BLE stack
|
||||
go func() {
|
||||
_, err := notifyChar.Write([]byte(response + "\n"))
|
||||
_, err := notifyChar.Write([]byte(response + "\r\n"))
|
||||
if err != nil {
|
||||
logger.Err("Failed to send response: %v", err)
|
||||
}
|
||||
|
||||
@@ -56,13 +56,24 @@ func (s *DeviceState) handleWriteCommand(cmd string) string {
|
||||
return "ERRORE: I comandi 'W' sono accettati solo in modalità programmazione."
|
||||
}
|
||||
|
||||
// Parse command format: "W<id> <value>"
|
||||
parts := strings.Fields(cmd)
|
||||
// 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>"
|
||||
parts := strings.SplitN(cmd, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return "ERRORE: Formato comando non valido"
|
||||
}
|
||||
|
||||
// Extract parameter ID
|
||||
// Extract parameter ID (remove "W" prefix from first part)
|
||||
idStr := strings.TrimPrefix(parts[0], "W")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
@@ -97,6 +108,15 @@ func (s *DeviceState) handleReadCommand(cmd string) string {
|
||||
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
|
||||
idStr := strings.TrimPrefix(cmd, "R")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
@@ -131,8 +151,8 @@ func isValidParamID(id int) bool {
|
||||
|
||||
// isReadableParamID checks if the parameter ID is readable
|
||||
func isReadableParamID(id int) bool {
|
||||
// According to docs.toon, only R1, R2, R20 are documented
|
||||
readableIDs := []int{1, 2, 20}
|
||||
// All writable parameters are also readable
|
||||
readableIDs := []int{1, 2, 3, 4, 11, 12, 20}
|
||||
for _, v := range readableIDs {
|
||||
if id == v {
|
||||
return true
|
||||
@@ -147,9 +167,9 @@ func validateParamRange(id, value int) bool {
|
||||
1: {0, 2000}, // W1 - Min Threshold
|
||||
2: {0, 4095}, // W2 - Max Threshold
|
||||
3: {1, 50}, // W3 - Pulse Count
|
||||
4: {10, 1000}, // W4 - Time Window
|
||||
11: {0, 1000}, // W11 - Min Pulse Duration
|
||||
12: {0, 1000}, // W12 - Max Pulse Duration
|
||||
4: {1000, 60000},// W4 - Time Window
|
||||
11: {5, 300}, // W11 - Min Pulse Duration
|
||||
12: {5, 300}, // W12 - Max Pulse Duration
|
||||
20: {0, 255}, // W20 - Gain (Wiper)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ type DeviceState struct {
|
||||
// W4=40 (Time Window), W20=128 (Gain)
|
||||
params map[int]int
|
||||
|
||||
ncParam bool // NC parameter: true = NO (1), false = NC (0), default is NO
|
||||
|
||||
sensorValue int // Default 2067 (ideal rest value)
|
||||
jitterRange int // Default 10 (means ±10)
|
||||
|
||||
@@ -33,14 +35,15 @@ func NewDeviceState() *DeviceState {
|
||||
alarmActive: false,
|
||||
alarmType: "",
|
||||
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)
|
||||
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)
|
||||
4: 30500, // W4 - Time Window (middle of 1000-60000)
|
||||
11: 152, // W11 - Min Pulse Duration (middle of 5-300)
|
||||
12: 152, // W12 - Max Pulse Duration (middle of 5-300)
|
||||
20: 127, // W20 - Gain (middle of 0-255)
|
||||
},
|
||||
ncParam: true, // Default NO mode (1)
|
||||
sensorValue: 2067,
|
||||
jitterRange: 10,
|
||||
connectedClients: 0,
|
||||
@@ -121,14 +124,15 @@ func (s *DeviceState) ResetToDefaults() {
|
||||
s.alarmActive = false
|
||||
s.alarmType = ""
|
||||
s.params = map[int]int{
|
||||
1: 1000,
|
||||
1: 127,
|
||||
2: 2047,
|
||||
3: 25,
|
||||
4: 505,
|
||||
11: 0,
|
||||
12: 1000,
|
||||
4: 30500,
|
||||
11: 152,
|
||||
12: 152,
|
||||
20: 127,
|
||||
}
|
||||
s.ncParam = true // Default NO mode (1)
|
||||
s.sensorValue = 2067
|
||||
s.jitterRange = 10
|
||||
}
|
||||
@@ -176,6 +180,20 @@ func (s *DeviceState) GetAlarmState() (bool, string) {
|
||||
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
|
||||
func (s *DeviceState) GetAllParams() map[int]int {
|
||||
s.mu.RLock()
|
||||
|
||||
@@ -70,6 +70,11 @@ func (l *LogBuffer) Err(format string, args ...any) {
|
||||
l.Log("ERR", fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Warn logs a WARN level message
|
||||
func (l *LogBuffer) Warn(format string, args ...any) {
|
||||
l.Log("WARN", fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Entries returns a copy of all log entries
|
||||
func (l *LogBuffer) Entries() []LogEntry {
|
||||
l.mu.RLock()
|
||||
|
||||
@@ -37,6 +37,7 @@ type Model struct {
|
||||
state *device.DeviceState
|
||||
logBuffer *LogBuffer
|
||||
notifyCh chan string // Channel to send alarm notifications
|
||||
disconnectCh chan struct{} // Channel to trigger disconnect all clients
|
||||
|
||||
controls []Control
|
||||
focusedCtrl int
|
||||
@@ -51,11 +52,12 @@ type Model struct {
|
||||
}
|
||||
|
||||
// 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{
|
||||
state: state,
|
||||
logBuffer: logBuffer,
|
||||
notifyCh: notifyCh,
|
||||
disconnectCh: disconnectCh,
|
||||
focusedCtrl: 0,
|
||||
focusedPanel: PanelControls,
|
||||
logOffset: 0,
|
||||
@@ -84,13 +86,19 @@ func NewModel(state *device.DeviceState, logBuffer *LogBuffer, notifyCh chan str
|
||||
Type: ControlButton,
|
||||
Action: func() {
|
||||
state.TriggerAlarm("TAMPER")
|
||||
logBuffer.Info("Tamper alarm triggered manually")
|
||||
logBuffer.Info("Tamper alarm triggered - disconnecting all clients")
|
||||
if notifyCh != nil {
|
||||
select {
|
||||
case notifyCh <- "ALARM: TAMPER":
|
||||
default:
|
||||
}
|
||||
}
|
||||
if disconnectCh != nil {
|
||||
select {
|
||||
case disconnectCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -158,9 +158,9 @@ func (m Model) renderControlsPanel(width int) string {
|
||||
1: {0, 2000},
|
||||
2: {0, 4095},
|
||||
3: {1, 50},
|
||||
4: {10, 1000},
|
||||
11: {0, 1000},
|
||||
12: {0, 1000},
|
||||
4: {1000, 60000},
|
||||
11: {5, 300},
|
||||
12: {5, 300},
|
||||
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))
|
||||
}
|
||||
|
||||
// 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
|
||||
b.WriteString("\n")
|
||||
b.WriteString(titleStyle.Render("Status"))
|
||||
|
||||
Reference in New Issue
Block a user