Files
ShackMaster/internal/devices/flexradio/flexradio.go
2026-02-28 11:01:03 +01:00

841 lines
18 KiB
Go

package flexradio
import (
"bufio"
"fmt"
"log"
"net"
"strconv"
"strings"
"sync"
"time"
"unicode"
)
type Client struct {
host string
port int
conn net.Conn
reader *bufio.Reader
connMu sync.Mutex
writeMu sync.Mutex
lastStatus *Status
statusMu sync.RWMutex
cmdSeq int
cmdSeqMu sync.Mutex
running bool
stopChan chan struct{}
reconnectInterval time.Duration
reconnectAttempts int
maxReconnectDelay time.Duration
radioInfo map[string]string
radioInfoMu sync.RWMutex
lastInfoCheck time.Time
infoCheckTimer *time.Timer
activeSlices []int
activeSlicesMu sync.RWMutex
sliceListTimer *time.Timer
onFrequencyChange func(freqMHz float64)
checkTransmitAllowed func() bool
// Track current slice frequency
currentFreq float64
currentFreqMu sync.RWMutex
}
func New(host string, port int) *Client {
return &Client{
host: host,
port: port,
stopChan: make(chan struct{}),
reconnectInterval: 5 * time.Second,
maxReconnectDelay: 60 * time.Second,
radioInfo: make(map[string]string),
activeSlices: []int{},
lastStatus: &Status{
Connected: false,
RadioOn: false,
Tx: false, // Initialisé à false
ActiveSlices: 0,
Frequency: 0,
},
currentFreq: 0,
}
}
// SetReconnectInterval sets the reconnection interval
func (c *Client) SetReconnectInterval(interval time.Duration) {
c.reconnectInterval = interval
}
// SetMaxReconnectDelay sets the maximum delay for exponential backoff
func (c *Client) SetMaxReconnectDelay(delay time.Duration) {
c.maxReconnectDelay = delay
}
// SetFrequencyChangeCallback sets the callback function called when frequency changes
func (c *Client) SetFrequencyChangeCallback(callback func(freqMHz float64)) {
c.onFrequencyChange = callback
}
// SetTransmitCheckCallback sets the callback to check if transmit is allowed
func (c *Client) SetTransmitCheckCallback(callback func() bool) {
c.checkTransmitAllowed = callback
}
func (c *Client) Connect() error {
c.connMu.Lock()
defer c.connMu.Unlock()
if c.conn != nil {
return nil
}
addr := fmt.Sprintf("%s:%d", c.host, c.port)
log.Printf("FlexRadio: Connecting to %s...", addr)
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
c.conn = conn
c.reader = bufio.NewReader(conn)
c.reconnectAttempts = 0
log.Println("FlexRadio: TCP connection established")
return nil
}
func (c *Client) Start() error {
if c.running {
return nil
}
// Try initial connection
if err := c.Connect(); err != nil {
log.Printf("FlexRadio: Initial connection failed: %v", err)
}
// Update connected status
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.Connected = (c.conn != nil)
c.lastStatus.RadioOn = false
}
c.statusMu.Unlock()
c.running = true
// Start message listener
go c.messageLoop()
// Start reconnection monitor
go c.reconnectionMonitor()
// Start radio status checker
go c.radioStatusChecker()
// Start slice list checker
go c.sliceListChecker()
// Try to get initial radio info and subscribe to slices
if c.conn != nil {
go func() {
time.Sleep(500 * time.Millisecond)
c.SendInfo()
time.Sleep(500 * time.Millisecond)
c.SendSliceList()
time.Sleep(500 * time.Millisecond)
c.SubscribeToSlices()
}()
}
return nil
}
func (c *Client) Stop() {
if !c.running {
return
}
c.running = false
close(c.stopChan)
// Stop timers
if c.infoCheckTimer != nil {
c.infoCheckTimer.Stop()
}
if c.sliceListTimer != nil {
c.sliceListTimer.Stop()
}
c.connMu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
c.reader = nil
}
c.connMu.Unlock()
// Update status
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.Connected = false
c.lastStatus.RadioOn = false
c.lastStatus.RadioInfo = "Disconnected"
c.lastStatus.ActiveSlices = 0
c.lastStatus.Frequency = 0
c.lastStatus.Mode = ""
c.lastStatus.Tx = false
}
c.statusMu.Unlock()
}
// Helper functions for common commands
func (c *Client) SendInfo() error {
return c.sendCommand("info")
}
func (c *Client) SendSliceList() error {
return c.sendCommand("slice list")
}
func (c *Client) SubscribeToSlices() error {
return c.sendCommand("sub slice all")
}
func (c *Client) sendCommand(cmd string) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
c.connMu.Lock()
conn := c.conn
c.connMu.Unlock()
if conn == nil {
return fmt.Errorf("not connected")
}
seq := c.getNextSeq()
fullCmd := fmt.Sprintf("C%d|%s\n", seq, cmd)
log.Printf("FlexRadio TX: %s", strings.TrimSpace(fullCmd))
_, err := conn.Write([]byte(fullCmd))
if err != nil {
// Mark connection as broken
c.connMu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
c.reader = nil
}
c.connMu.Unlock()
// Update status
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.Connected = false
c.lastStatus.RadioOn = false
c.lastStatus.RadioInfo = "Connection lost"
}
c.statusMu.Unlock()
return fmt.Errorf("failed to send command: %w", err)
}
return nil
}
func (c *Client) getNextSeq() int {
c.cmdSeqMu.Lock()
defer c.cmdSeqMu.Unlock()
c.cmdSeq++
return c.cmdSeq
}
func (c *Client) messageLoop() {
log.Println("FlexRadio: Message loop started")
for c.running {
c.connMu.Lock()
if c.conn == nil || c.reader == nil {
c.connMu.Unlock()
time.Sleep(1 * time.Second)
continue
}
c.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
line, err := c.reader.ReadString('\n')
c.connMu.Unlock()
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
continue
}
log.Printf("FlexRadio: Read error: %v", err)
c.connMu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
c.reader = nil
}
c.connMu.Unlock()
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.Connected = false
c.lastStatus.RadioOn = false
c.lastStatus.RadioInfo = "Connection lost"
}
c.statusMu.Unlock()
continue
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
c.handleMessage(line)
}
log.Println("FlexRadio: Message loop stopped")
}
// Message handling - SIMPLIFIED VERSION
func (c *Client) handleMessage(msg string) {
msg = strings.TrimSpace(msg)
if msg == "" {
return
}
// DEBUG: Log tous les messages reçus
log.Printf("FlexRadio RAW: %s", msg)
// Vérifier le type de message
if len(msg) < 2 {
return
}
// Messages commençant par R (réponses)
if msg[0] == 'R' {
c.handleCommandResponse(msg)
return
}
// Messages commençant par S (statut)
if msg[0] == 'S' {
// Enlever le préfixe S
msg = msg[1:]
// Séparer handle et données
parts := strings.SplitN(msg, "|", 2)
if len(parts) < 2 {
return
}
handle := parts[0]
data := parts[1]
// Parser les paires clé=valeur
statusMap := make(map[string]string)
pairs := strings.Fields(data)
for _, pair := range pairs {
if kv := strings.SplitN(pair, "=", 2); len(kv) == 2 {
statusMap[kv[0]] = kv[1]
}
}
// Identifier le type de message
if strings.Contains(data, "interlock") {
c.handleInterlockStatus(handle, statusMap)
} else if strings.Contains(data, "slice") {
// Extraire le numéro de slice depuis le message (ex: "slice 0 RF_frequency=14.225")
sliceNum := -1
fields := strings.Fields(data)
for i, f := range fields {
if f == "slice" && i+1 < len(fields) {
if n, err := strconv.Atoi(fields[i+1]); err == nil {
sliceNum = n
}
break
}
}
c.handleSliceStatus(handle, statusMap, sliceNum)
} else if strings.Contains(data, "radio") {
c.handleRadioStatus(handle, statusMap)
} else {
// Vérifier si c'est une mise à jour de fréquence
if freqStr, ok := statusMap["RF_frequency"]; ok {
c.handleFrequencyUpdate(handle, freqStr, statusMap)
} else {
log.Printf("FlexRadio: Message inconnu (handle=%s): %s", handle, data)
}
}
return
}
// Autres types de messages
switch msg[0] {
case 'V':
log.Printf("FlexRadio: Version/Handle: %s", msg)
case 'M':
log.Printf("FlexRadio: Message: %s", msg)
default:
log.Printf("FlexRadio: Type de message inconnu: %s", msg)
}
}
func (c *Client) handleSliceStatus(handle string, statusMap map[string]string, sliceNum int) {
c.statusMu.Lock()
defer c.statusMu.Unlock()
if c.lastStatus == nil {
return
}
// Mettre à jour le nombre de slices actives
c.lastStatus.ActiveSlices = 1
// Mettre à jour la fréquence
if rfFreq, ok := statusMap["RF_frequency"]; ok {
if freq, err := strconv.ParseFloat(rfFreq, 64); err == nil && freq > 0 {
oldFreq := c.lastStatus.Frequency
// Mettre à jour la fréquence affichée uniquement si c'est slice 0
if sliceNum == 0 || sliceNum == -1 {
c.lastStatus.Frequency = freq
c.lastStatus.RadioInfo = fmt.Sprintf("Active on %.3f MHz", freq)
}
// Déclencher le callback UNIQUEMENT pour la slice 0
// Les slices 1, 2, 3 ne contrôlent pas l'Ultrabeam
if sliceNum == 0 && oldFreq != freq && c.onFrequencyChange != nil {
log.Printf("FlexRadio: Slice 0 frequency changed to %.3f MHz -> triggering Ultrabeam callback", freq)
go c.onFrequencyChange(freq)
} else if sliceNum > 0 {
log.Printf("FlexRadio: Slice %d frequency changed to %.3f MHz -> ignored for Ultrabeam", sliceNum, freq)
}
} else if freq == 0 {
// Fréquence 0 = slice inactive
if sliceNum == 0 || sliceNum == -1 {
c.lastStatus.Frequency = 0
c.lastStatus.RadioInfo = "Slice inactive"
}
}
}
// Mettre à jour le mode
if mode, ok := statusMap["mode"]; ok {
c.lastStatus.Mode = mode
}
// NE PAS utiliser tx du slice pour l'état TX réel
// tx=1 dans le slice signifie seulement "capable de TX", pas "en train de TX"
// L'état TX réel vient de l'interlock
}
func (c *Client) handleCommandResponse(msg string) {
// Format: R<seq>|<status>|<data>
parts := strings.SplitN(msg, "|", 3)
if len(parts) < 3 {
log.Printf("FlexRadio: Malformed response: %s", msg)
return
}
seqStr := strings.TrimPrefix(parts[0], "R")
status := parts[1]
data := parts[2]
seq, _ := strconv.Atoi(seqStr)
if status != "0" {
log.Printf("FlexRadio: Command error (seq=%d): %s", seq, msg)
return
}
log.Printf("FlexRadio: Command success (seq=%d)", seq)
// Identifier le type de réponse par son contenu
switch {
case strings.Contains(data, "model="):
c.parseInfoResponse(data)
case isSliceListResponse(data):
c.parseSliceListResponse(data)
default:
log.Printf("FlexRadio: Generic response: %s", data)
}
}
func isSliceListResponse(data string) bool {
data = strings.TrimSpace(data)
if data == "" {
return true
}
for _, char := range data {
if !unicode.IsDigit(char) && char != ' ' {
return false
}
}
return true
}
func (c *Client) handleInterlockStatus(handle string, statusMap map[string]string) {
c.statusMu.Lock()
defer c.statusMu.Unlock()
if state, ok := statusMap["state"]; ok {
c.lastStatus.Tx = (state == "TRANSMITTING" || state == "TUNE")
log.Printf("FlexRadio: Interlock state=%s, TX=%v", state, c.lastStatus.Tx)
}
}
func (c *Client) handleRadioStatus(handle string, statusMap map[string]string) {
c.statusMu.Lock()
defer c.statusMu.Unlock()
if c.lastStatus == nil {
return
}
// Mettre à jour les informations radio
c.lastStatus.RadioOn = true
c.lastStatus.Connected = true
// Mettre à jour le nombre de slices
if slices, ok := statusMap["slices"]; ok {
if num, err := strconv.Atoi(slices); err == nil {
c.lastStatus.NumSlices = num
}
}
// Mettre à jour le callsign
if callsign, ok := statusMap["callsign"]; ok {
c.lastStatus.Callsign = callsign
}
// Mettre à jour les autres infos
if nickname, ok := statusMap["nickname"]; ok {
c.lastStatus.RadioInfo = fmt.Sprintf("Radio: %s", nickname)
}
}
func (c *Client) handleFrequencyUpdate(handle string, freqStr string, statusMap map[string]string) {
c.statusMu.Lock()
defer c.statusMu.Unlock()
if c.lastStatus == nil {
return
}
// Parser la fréquence
// Note: ce chemin est un fallback sans numéro de slice connu.
// On met à jour l'affichage mais on ne déclenche PAS le callback Ultrabeam
// (les vrais changements de slice 0 passent par handleSliceStatus)
if freq, err := strconv.ParseFloat(freqStr, 64); err == nil && freq > 0 {
c.lastStatus.Frequency = freq
c.lastStatus.RadioInfo = fmt.Sprintf("Active on %.3f MHz", freq)
}
log.Printf("FlexRadio: Frequency update: %s MHz", freqStr)
}
func (c *Client) parseInfoResponse(data string) {
log.Printf("FlexRadio: Parsing info response: %s", data)
pairs := []string{}
current := ""
inQuotes := false
for _, char := range data {
if char == '"' {
inQuotes = !inQuotes
}
if char == ',' && !inQuotes {
pairs = append(pairs, strings.TrimSpace(current))
current = ""
} else {
current += string(char)
}
}
if current != "" {
pairs = append(pairs, strings.TrimSpace(current))
}
c.radioInfoMu.Lock()
c.radioInfo = make(map[string]string)
for _, pair := range pairs {
kv := strings.SplitN(pair, "=", 2)
if len(kv) == 2 {
key := strings.TrimSpace(kv[0])
value := strings.TrimSpace(kv[1])
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
value = value[1 : len(value)-1]
}
c.radioInfo[key] = value
}
}
c.radioInfoMu.Unlock()
// Mettre à jour le statut
c.updateRadioStatus(true, "Radio is on")
go func() {
time.Sleep(300 * time.Millisecond)
c.SendSliceList()
// S'abonner aux mises à jour
time.Sleep(200 * time.Millisecond)
c.sendCommand("sub slice all")
time.Sleep(100 * time.Millisecond)
c.sendCommand("sub interlock 0")
}()
}
func (c *Client) parseSliceListResponse(data string) {
slices := []int{}
if strings.TrimSpace(data) != "" {
parts := strings.Fields(data)
for _, part := range parts {
if sliceNum, err := strconv.Atoi(part); err == nil {
slices = append(slices, sliceNum)
}
}
}
c.activeSlicesMu.Lock()
c.activeSlices = slices
c.activeSlicesMu.Unlock()
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.ActiveSlices = len(slices)
// NE PAS effacer la fréquence ici !
// La fréquence est gérée par handleSliceStatus
// Seulement mettre à jour RadioInfo si vraiment pas de slices
if len(slices) == 0 && c.lastStatus.Frequency == 0 {
c.lastStatus.RadioInfo = "Radio is on without any active slice"
} else if len(slices) == 0 && c.lastStatus.Frequency > 0 {
// Cas spécial : fréquence mais pas de slice dans la liste
// Peut arriver temporairement, garder l'info actuelle
c.lastStatus.RadioInfo = fmt.Sprintf("Active on %.3f MHz", c.lastStatus.Frequency)
}
}
c.statusMu.Unlock()
log.Printf("FlexRadio: Active slices updated: %v (total: %d)", slices, len(slices))
}
func (c *Client) updateRadioStatus(isOn bool, info string) {
c.statusMu.Lock()
defer c.statusMu.Unlock()
if c.lastStatus == nil {
return
}
c.lastStatus.RadioOn = isOn
c.lastStatus.RadioInfo = info
c.radioInfoMu.RLock()
if callsign, ok := c.radioInfo["callsign"]; ok {
c.lastStatus.Callsign = callsign
}
if model, ok := c.radioInfo["model"]; ok {
c.lastStatus.Model = model
}
if softwareVer, ok := c.radioInfo["software_ver"]; ok {
c.lastStatus.SoftwareVer = softwareVer
}
if numSlicesStr, ok := c.radioInfo["num_slice"]; ok {
if numSlices, err := strconv.Atoi(numSlicesStr); err == nil {
c.lastStatus.NumSlices = numSlices
}
}
c.radioInfoMu.RUnlock()
if isOn && c.lastStatus.Frequency == 0 && c.lastStatus.ActiveSlices == 0 {
c.lastStatus.RadioInfo = "Radio is on without any active slice"
}
}
func (c *Client) reconnectionMonitor() {
log.Println("FlexRadio: Reconnection monitor started")
for c.running {
c.connMu.Lock()
connected := (c.conn != nil)
c.connMu.Unlock()
if !connected {
c.reconnectAttempts++
delay := c.calculateReconnectDelay()
log.Printf("FlexRadio: Attempting to reconnect in %v (attempt %d)...", delay, c.reconnectAttempts)
select {
case <-time.After(delay):
if err := c.reconnect(); err != nil {
log.Printf("FlexRadio: Reconnection attempt %d failed: %v", c.reconnectAttempts, err)
} else {
log.Printf("FlexRadio: Reconnected successfully on attempt %d", c.reconnectAttempts)
c.reconnectAttempts = 0
go func() {
time.Sleep(500 * time.Millisecond)
c.SendInfo()
}()
}
case <-c.stopChan:
return
}
} else {
select {
case <-time.After(10 * time.Second):
case <-c.stopChan:
return
}
}
}
}
func (c *Client) calculateReconnectDelay() time.Duration {
delay := c.reconnectInterval
if c.reconnectAttempts > 1 {
multiplier := 1 << (c.reconnectAttempts - 1)
delay = c.reconnectInterval * time.Duration(multiplier)
if delay > c.maxReconnectDelay {
delay = c.maxReconnectDelay
}
}
return delay
}
func (c *Client) reconnect() error {
c.connMu.Lock()
defer c.connMu.Unlock()
// Close existing connection if any
if c.conn != nil {
c.conn.Close()
c.conn = nil
c.reader = nil
}
addr := fmt.Sprintf("%s:%d", c.host, c.port)
log.Printf("FlexRadio: Reconnecting to %s...", addr)
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.Connected = false
c.lastStatus.RadioOn = false
c.lastStatus.RadioInfo = "Disconnected"
}
c.statusMu.Unlock()
return fmt.Errorf("reconnect failed: %w", err)
}
c.conn = conn
c.reader = bufio.NewReader(conn)
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.Connected = true
c.lastStatus.RadioInfo = "TCP connected, checking radio..."
}
c.statusMu.Unlock()
log.Println("FlexRadio: TCP connection reestablished")
return nil
}
func (c *Client) radioStatusChecker() {
c.infoCheckTimer = time.NewTimer(10 * time.Second)
for c.running {
select {
case <-c.infoCheckTimer.C:
c.SendInfo()
c.infoCheckTimer.Reset(10 * time.Second)
case <-c.stopChan:
return
}
}
}
func (c *Client) sliceListChecker() {
c.sliceListTimer = time.NewTimer(10 * time.Second)
for c.running {
select {
case <-c.sliceListTimer.C:
if c.IsRadioOn() {
c.SendSliceList()
}
c.sliceListTimer.Reset(10 * time.Second)
case <-c.stopChan:
return
}
}
}
func (c *Client) GetStatus() (*Status, error) {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
if c.lastStatus == nil {
return &Status{
Connected: false,
RadioOn: false,
Tx: false,
RadioInfo: "Not initialized",
}, nil
}
// Créer une copie
status := *c.lastStatus
return &status, nil
}
// IsRadioOn returns true if radio is powered on and responding
func (c *Client) IsRadioOn() bool {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
if c.lastStatus == nil {
return false
}
return c.lastStatus.RadioOn
}