12 Commits

Author SHA1 Message Date
02fec72c43 up 2026-02-28 11:49:23 +01:00
08bbaab94b update 2026-02-28 11:01:03 +01:00
238716fdae up 2026-02-28 10:52:04 +01:00
0f2dc76d55 up 2026-01-16 01:28:28 +01:00
5ced01c010 update 2026-01-16 01:17:28 +01:00
30688ad644 working reconnect and slices 2026-01-15 22:39:39 +01:00
3e169fe615 update 2026-01-15 22:34:44 +01:00
21db2addff up 2026-01-15 22:19:32 +01:00
130efeee83 up 2026-01-15 20:30:38 +01:00
4eeec6bdf6 working 2026-01-15 06:51:25 +01:00
de3fda2648 Revert "updated frontend"
This reverts commit b8884d89e3.
2026-01-15 06:44:29 +01:00
c6ceeb103b update sunset 2026-01-15 06:26:49 +01:00
23 changed files with 948 additions and 200 deletions

87
Makefile Normal file
View File

@@ -0,0 +1,87 @@
# Variables
BINARY_NAME=ShackMaster.exe
FRONTEND_DIR=web
BACKEND_DIR=cmd/server
DIST_DIR=$(FRONTEND_DIR)/dist
GO_FILES=$(shell find . -name '*.go' -not -path "./$(FRONTEND_DIR)/*")
CGO_ENABLED ?= 1
GOFLAGS = CGO_ENABLED=$(CGO_ENABLED)
.PHONY: all build frontend backend run clean dev help install-deps
# Commande par défaut
all: build
## help: Affiche cette aide
help:
@echo "ShackMaster - Makefile"
@echo ""
@echo "Commandes disponibles:"
@echo " make build - Build complet (frontend + backend)"
@echo " make frontend - Build uniquement le frontend"
@echo " make backend - Build uniquement le backend Go"
@echo " make run - Build et lance l'application"
@echo " make dev - Lance le frontend en mode dev"
@echo " make clean - Nettoie les fichiers générés"
@echo " make install-deps - Installe toutes les dépendances"
@echo " make help - Affiche cette aide"
## install-deps: Installe les dépendances npm
install-deps:
@echo "[1/2] Installation des dependances npm..."
cd $(FRONTEND_DIR) && npm install
@echo "Dependances installees"
@echo ""
@echo "[2/2] Verification de Go..."
@go version
@echo "Go est installe"
## frontend: Build le frontend Svelte
frontend:
@echo "Building frontend..."
cd $(FRONTEND_DIR) && npm run build
xcopy /E /I /Y web\dist cmd\server\web\dist
@echo "Frontend built successfully"
## backend: Build le backend Go
backend: frontend
@echo "Building Go binary..."
cd $(BACKEND_DIR) && go build -ldflags -H=windowsgui .
@echo "Backend built successfully"
## build: Build complet (frontend + backend)
build: install-deps frontend backend
@echo ""
@echo "====================================="
@echo " BUILD COMPLETE!"
@echo "====================================="
@echo ""
@echo "Run: ./$(BINARY_NAME)"
@echo ""
## run: Build et lance l'application
run: build
@echo "Starting ShackMaster..."
@echo ""
./$(BINARY_NAME)
## dev: Lance le frontend en mode développement (hot reload)
dev:
@echo "Starting frontend dev server..."
@echo "Frontend: http://localhost:3000"
@echo "Backend: http://localhost:8080"
@echo ""
cd $(FRONTEND_DIR) && npm run dev
## clean: Nettoie les fichiers générés
clean:
@echo "Cleaning build files..."
@if exist $(BINARY_NAME) del /f /q $(BINARY_NAME)
@if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR)
@echo "Clean complete"
## watch: Build auto lors des changements (nécessite watchexec)
watch:
@echo "Watching for changes..."
@echo "Install watchexec: choco install watchexec"
watchexec -w . -e go -- make build

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,11 +7,10 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-BqlArLJ0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bl7hatTL.css">
<script type="module" crossorigin src="/assets/index-Ci4y1GIJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B1UmG2DI.css">
</head>
<body>
<div id="app"></div>
</body>

View File

@@ -121,6 +121,9 @@ func (dm *DeviceManager) Initialize() error {
dm.config.Devices.FlexRadio.Port,
)
dm.flexRadio.SetReconnectInterval(3 * time.Second) // Retry every 3 seconds
dm.flexRadio.SetMaxReconnectDelay(30 * time.Second) // Max 30 second delay
// Set callback for immediate frequency changes (no waiting for update cycle)
dm.flexRadio.SetFrequencyChangeCallback(func(freqMHz float64) {
dm.handleFrequencyChange(freqMHz)
@@ -421,9 +424,7 @@ func (dm *DeviceManager) updateStatus() {
// Check cooldown to prevent rapid fire commands
timeSinceLastUpdate := time.Since(dm.lastFreqUpdateTime)
if timeSinceLastUpdate < dm.freqUpdateCooldown {
log.Printf("Auto-track: Cooldown active (%v remaining), skipping update", dm.freqUpdateCooldown-timeSinceLastUpdate)
} else {
if timeSinceLastUpdate > dm.freqUpdateCooldown {
log.Printf("Auto-track (%s): Frequency differs by %d kHz, updating Ultrabeam to %d kHz (direction=%d)", radioSource, freqDiff, radioFreqKhz, directionToUse)
// Send to Ultrabeam with saved or current direction

View File

@@ -9,6 +9,7 @@ import (
"strings"
"sync"
"time"
"unicode"
)
type Client struct {
@@ -17,8 +18,8 @@ type Client struct {
conn net.Conn
reader *bufio.Reader
connMu sync.Mutex // For connection management
writeMu sync.Mutex // For writing to connection (separate from reads)
connMu sync.Mutex
writeMu sync.Mutex
lastStatus *Status
statusMu sync.RWMutex
@@ -29,12 +30,25 @@ type Client struct {
running bool
stopChan chan struct{}
// Callbacks
onFrequencyChange func(freqMHz float64)
checkTransmitAllowed func() bool // Returns true if transmit allowed (motors not moving)
// Reconnection settings
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 {
@@ -42,13 +56,31 @@ func New(host string, port int) *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,
},
reconnectInterval: 5 * time.Second, // Reconnect every 5 seconds if disconnected
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
@@ -72,14 +104,14 @@ func (c *Client) Connect() error {
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
log.Printf("FlexRadio: Connection failed: %v", err)
return fmt.Errorf("failed to connect: %w", err)
}
c.conn = conn
c.reader = bufio.NewReader(conn)
c.reconnectAttempts = 0
log.Println("FlexRadio: Connected successfully")
log.Println("FlexRadio: TCP connection established")
return nil
}
@@ -88,37 +120,48 @@ func (c *Client) Start() error {
return nil
}
c.running = true
// Try initial connection
if err := c.Connect(); err != nil {
log.Printf("FlexRadio: Initial connection failed: %v", err)
}
// Try initial connection but don't fail if it doesn't work
// The messageLoop will handle reconnection
err := c.Connect()
if err != nil {
log.Printf("FlexRadio: Initial connection failed, will retry: %v", err)
} else {
// Update connected status
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.Connected = true
c.lastStatus.Connected = (c.conn != nil)
c.lastStatus.RadioOn = false
}
c.statusMu.Unlock()
// Subscribe to slice updates for frequency tracking
c.subscribeToSlices()
}
c.running = true
// Start message listener (handles reconnection)
// Start message listener
go c.messageLoop()
return nil
}
// Start reconnection monitor
go c.reconnectionMonitor()
func (c *Client) subscribeToSlices() {
log.Println("FlexRadio: Subscribing to slice updates...")
_, err := c.sendCommand("sub slice all")
if err != nil {
log.Printf("FlexRadio: Warning - failed to subscribe to slices: %v", err)
// 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() {
@@ -129,6 +172,14 @@ func (c *Client) Stop() {
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()
@@ -137,23 +188,34 @@ func (c *Client) Stop() {
}
c.connMu.Unlock()
// Update connected status
// 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()
}
func (c *Client) getNextSeq() int {
c.cmdSeqMu.Lock()
defer c.cmdSeqMu.Unlock()
c.cmdSeq++
return c.cmdSeq
// Helper functions for common commands
func (c *Client) SendInfo() error {
return c.sendCommand("info")
}
func (c *Client) sendCommand(cmd string) (string, error) {
// Use writeMu instead of connMu to avoid blocking on messageLoop reads
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()
@@ -162,7 +224,7 @@ func (c *Client) sendCommand(cmd string) (string, error) {
c.connMu.Unlock()
if conn == nil {
return "", fmt.Errorf("not connected")
return fmt.Errorf("not connected")
}
seq := c.getNextSeq()
@@ -172,80 +234,7 @@ func (c *Client) sendCommand(cmd string) (string, error) {
_, err := conn.Write([]byte(fullCmd))
if err != nil {
c.connMu.Lock()
c.conn = nil
c.reader = nil
c.connMu.Unlock()
return "", fmt.Errorf("failed to send command: %w", err)
}
return "", nil
}
func (c *Client) messageLoop() {
log.Println("FlexRadio: Message loop started")
reconnectTicker := time.NewTicker(c.reconnectInterval)
defer reconnectTicker.Stop()
for c.running {
c.connMu.Lock()
isConnected := c.conn != nil && c.reader != nil
c.connMu.Unlock()
if !isConnected {
// Update status to disconnected
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.Connected = false
}
c.statusMu.Unlock()
// Wait for reconnect interval
select {
case <-reconnectTicker.C:
log.Println("FlexRadio: Attempting to reconnect...")
if err := c.Connect(); err != nil {
log.Printf("FlexRadio: Reconnect failed: %v", err)
continue
}
// Successfully reconnected
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.Connected = true
}
c.statusMu.Unlock()
// Re-subscribe to slices after reconnection
c.subscribeToSlices()
case <-c.stopChan:
log.Println("FlexRadio: Message loop stopping (stop signal received)")
return
}
continue
}
// Read from connection
c.connMu.Lock()
if c.conn == nil || c.reader == nil {
c.connMu.Unlock()
continue
}
// Set read deadline to allow periodic checks
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() {
// Timeout is expected, continue
continue
}
log.Printf("FlexRadio: Read error: %v", err)
// Mark connection as broken
c.connMu.Lock()
if c.conn != nil {
c.conn.Close()
@@ -254,14 +243,65 @@ func (c *Client) messageLoop() {
}
c.connMu.Unlock()
// Update connected status
// 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()
log.Println("FlexRadio: Connection lost, will attempt reconnection...")
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
}
@@ -276,87 +316,495 @@ func (c *Client) messageLoop() {
log.Println("FlexRadio: Message loop stopped")
}
// Message handling - SIMPLIFIED VERSION
func (c *Client) handleMessage(msg string) {
// Response format: R<seq>|<status>|<data>
if strings.HasPrefix(msg, "R") {
c.handleResponse(msg)
msg = strings.TrimSpace(msg)
if msg == "" {
return
}
// Status format: S<handle>|<key>=<value> ...
if strings.HasPrefix(msg, "S") {
c.handleStatus(msg)
// DEBUG: Log tous les messages reçus
log.Printf("FlexRadio RAW: %s", msg)
// Vérifier le type de message
if len(msg) < 2 {
return
}
// Version/handle format: V<version>|H<handle>
if strings.HasPrefix(msg, "V") {
log.Printf("FlexRadio: Version/Handle received: %s", msg)
// Messages commençant par R (réponses)
if msg[0] == 'R' {
c.handleCommandResponse(msg)
return
}
// Message format: M<handle>|<message>
if strings.HasPrefix(msg, "M") {
log.Printf("FlexRadio: Message: %s", msg)
return
}
}
// Messages commençant par S (statut)
if msg[0] == 'S' {
// Enlever le préfixe S
msg = msg[1:]
func (c *Client) handleResponse(msg string) {
// Format: R<seq>|<status>|<data>
// Example: R21|0|000000F4
parts := strings.SplitN(msg, "|", 3)
if len(parts) < 2 {
return
}
status := parts[1]
if status != "0" {
log.Printf("FlexRadio: Command error: status=%s, message=%s", status, msg)
return
}
}
func (c *Client) handleStatus(msg string) {
// Format: S<handle>|<key>=<value> ...
// Séparer handle et données
parts := strings.SplitN(msg, "|", 2)
if len(parts) < 2 {
return
}
handle := parts[0]
data := parts[1]
// Parse key=value pairs
pairs := strings.Fields(data)
// Parser les paires clé=valeur
statusMap := make(map[string]string)
pairs := strings.Fields(data)
for _, pair := range pairs {
kv := strings.SplitN(pair, "=", 2)
if len(kv) == 2 {
if kv := strings.SplitN(pair, "=", 2); len(kv) == 2 {
statusMap[kv[0]] = kv[1]
}
}
// Check for slice updates (frequency changes)
if strings.Contains(msg, "slice") {
if rfFreq, ok := statusMap["RF_frequency"]; ok {
freq, err := strconv.ParseFloat(rfFreq, 64)
if err == nil {
// 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()
// Only log significant frequency changes (> 1 kHz)
if oldFreq == 0 || (freq-oldFreq)*(freq-oldFreq) > 0.001 {
log.Printf("FlexRadio: Frequency updated to %.6f MHz", freq)
log.Printf("FlexRadio: Active slices updated: %v (total: %d)", slices, len(slices))
}
// Trigger callback for immediate auto-track
if c.onFrequencyChange != nil {
go c.onFrequencyChange(freq)
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
}
}
}
@@ -366,14 +814,27 @@ func (c *Client) GetStatus() (*Status, error) {
defer c.statusMu.RUnlock()
if c.lastStatus == nil {
return &Status{Connected: false}, nil
return &Status{
Connected: false,
RadioOn: false,
Tx: false,
RadioInfo: "Not initialized",
}, nil
}
// Create a copy
// Créer une copie
status := *c.lastStatus
// DON'T lock connMu here - it causes 4-second blocking!
// The messageLoop updates Connected status, and we trust the cached value
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
}

View File

@@ -3,12 +3,16 @@ package flexradio
// Status represents the FlexRadio status
type Status struct {
Connected bool `json:"connected"`
InterlockID string `json:"interlock_id"`
InterlockState string `json:"interlock_state"`
Frequency float64 `json:"frequency"` // MHz
RadioOn bool `json:"radio_on"`
RadioInfo string `json:"radio_info"`
Frequency float64 `json:"frequency"` // Primary frequency in MHz
Mode string `json:"mode"`
Tx bool `json:"tx"` // Actually transmitting
ActiveSlices int `json:"active_slices"`
NumSlices int `json:"num_slices"`
Callsign string `json:"callsign"`
Model string `json:"model"`
Serial string `json:"serial"`
Version string `json:"version"`
SoftwareVer string `json:"software_ver"`
}
// InterlockState represents possible interlock states

View File

@@ -282,6 +282,7 @@
display: flex;
gap: 24px;
flex-wrap: wrap;
align-items: stretch;
}
.row > :global(*) {

View File

@@ -148,6 +148,9 @@
padding: 0;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
height: 100%;
display: flex;
flex-direction: column;
}
.card-header {
@@ -185,6 +188,7 @@
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
/* Sources */

View File

@@ -151,6 +151,9 @@
padding: 0;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
height: 100%;
display: flex;
flex-direction: column;
}
.card-header {
@@ -222,6 +225,7 @@
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
}
/* Power Display */

View File

@@ -368,6 +368,9 @@
padding: 0;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
height: 100%;
display: flex;
flex-direction: column;
}
.card-header {
@@ -405,6 +408,7 @@
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
}
.heading-controls-row {

View File

@@ -9,11 +9,16 @@
export let gustWarningThreshold = 50; // km/h
export let graylineWindow = 30; // minutes avant/après sunrise/sunset
// FlexRadio frequency and mode
// FlexRadio status
$: frequency = flexradio?.frequency || 0;
$: mode = flexradio?.mode || '';
$: txEnabled = flexradio?.tx || false;
$: connected = flexradio?.connected || false;
$: radioOn = flexradio?.radio_on || false;
$: radioInfo = flexradio?.radio_info || '';
$: callsign = flexradio?.callsign || '';
$: model = flexradio?.model || '';
$: activeSlices = flexradio?.active_slices || 0;
// Grayline calculation
let sunrise = null;
@@ -55,6 +60,16 @@
updateTimeToNextEvent();
}
$: console.log('FlexRadio status:', {
connected,
radioOn,
frequency,
activeSlices,
radioInfo,
callsign,
model
});
// Simplified sun calculation (based on NOAA algorithm)
function getSunTimes(date, lat, lon) {
const rad = Math.PI / 180;
@@ -228,6 +243,13 @@
$: currentBand = getBand(frequency);
$: bandColor = getBandColor(currentBand);
// Determine what to show for FlexRadio - MODIFIÉ
$: showFrequency = radioOn && frequency > 0;
$: showRadioOnNoSlice = radioOn && frequency === 0 && activeSlices === 0;
$: showRadioOnWithSliceNoFreq = radioOn && frequency === 0 && activeSlices > 0;
$: showNotConnected = !connected;
$: showConnectedNoRadio = connected && !radioOn;
</script>
<div class="status-banner" class:has-warning={hasAnyWarning}>
@@ -237,7 +259,8 @@
📻
</div>
{#if connected && frequency > 0}
{#if showFrequency}
<!-- Radio is on and has active slice with frequency -->
<div class="frequency-display">
<span class="frequency" style="--band-color: {bandColor}">
{formatFrequency(frequency)}
@@ -257,16 +280,57 @@
</span>
{/if}
<!-- AFFICHAGE TX - SEULEMENT SI TX EST VRAI -->
{#if txEnabled}
<span class="tx-indicator">
TX
</span>
{/if}
{:else}
<span class="no-signal">FlexRadio non connecté</span>
{:else if showRadioOnWithSliceNoFreq}
<!-- Radio is on with slice but frequency is 0 (maybe slice just created) -->
<div class="radio-status">
<span class="radio-on-indicator"></span>
<span class="radio-status-text">Slice active, waiting for frequency...</span>
{#if model}
<span class="model-badge">{model}</span>
{/if}
{#if callsign}
<span class="callsign-badge">{callsign}</span>
{/if}
</div>
{:else if showRadioOnNoSlice}
<!-- Radio is on but no active slice -->
<div class="radio-status">
<span class="radio-on-indicator"></span>
<span class="radio-status-text">{radioInfo || 'Radio is on'}</span>
{#if model}
<span class="model-badge">{model}</span>
{/if}
{#if callsign}
<span class="callsign-badge">{callsign}</span>
{/if}
</div>
{:else if showConnectedNoRadio}
<!-- TCP connected but radio not responding -->
<div class="radio-status">
<span class="radio-off-indicator"></span>
<span class="radio-status-text">TCP connected, radio off</span>
</div>
{:else if showNotConnected}
<!-- Not connected at all -->
<span class="no-signal">FlexRadio not connected</span>
{:else}
<!-- Default/unknown state -->
<span class="no-signal">Checking FlexRadio...</span>
{/if}
</div>
<!-- Separator -->
<div class="separator"></div>
@@ -304,7 +368,7 @@
{#if isGrayline}
<span class="grayline-badge" class:sunrise={graylineType === 'sunrise'} class:sunset={graylineType === 'sunset'}>
✨ GRAYLINE
✨ Grayline
</span>
{:else if timeToNextEvent}
<span class="next-event">
@@ -312,7 +376,7 @@
</span>
{/if}
{:else}
<span class="no-location">📍 Position non configurée</span>
<span class="no-location">📍 Position not set</span>
{/if}
</div>
@@ -342,7 +406,7 @@
{#if !hasAnyWarning}
<div class="status-ok">
<span class="ok-icon"></span>
<span class="ok-text">Météo OK</span>
<span class="ok-text">Weather OK</span>
</div>
{/if}
</div>
@@ -444,6 +508,53 @@
50% { opacity: 0.6; }
}
/* Radio status indicators */
.radio-status {
display: flex;
align-items: center;
gap: 8px;
}
.radio-on-indicator {
color: #22c55e;
font-size: 16px;
animation: pulse 2s infinite;
}
.radio-off-indicator {
color: #ef4444;
font-size: 16px;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.radio-status-text {
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
}
.model-badge {
padding: 3px 8px;
background: rgba(79, 195, 247, 0.2);
border: 1px solid rgba(79, 195, 247, 0.4);
border-radius: 4px;
font-size: 12px;
color: #4fc3f7;
}
.callsign-badge {
padding: 3px 8px;
background: rgba(34, 197, 94, 0.2);
border: 1px solid rgba(34, 197, 94, 0.4);
border-radius: 4px;
font-size: 12px;
font-weight: 600;
color: #22c55e;
}
.no-signal {
color: rgba(255, 255, 255, 0.4);
font-size: 14px;

View File

@@ -140,6 +140,9 @@
padding: 0;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
height: 100%;
display: flex;
flex-direction: column;
}
.card-header {
@@ -205,6 +208,7 @@
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
}
/* Power Display */
@@ -377,6 +381,7 @@
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
flex: 1;
}
.control-btn {

View File

@@ -163,10 +163,8 @@
<div class="card">
<div class="card-header">
<h2>Ultrabeam VL2.3</h2>
<div class="header-right">
<span class="status-dot" class:disconnected={!connected}></span>
</div>
</div>
<div class="metrics">
<!-- Current Status -->
@@ -323,20 +321,24 @@
<style>
.card {
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%);
border-radius: 16px;
padding: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
border: 1px solid rgba(79, 195, 247, 0.2);
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
border: 1px solid #2d3748;
border-radius: 8px;
padding: 0;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
height: 100%;
display: flex;
flex-direction: column;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid rgba(79, 195, 247, 0.3);
padding: 12px 16px;
background: rgba(79, 195, 247, 0.05);
border-bottom: 1px solid #2d3748;
}
.header-right {
@@ -347,12 +349,10 @@
h2 {
margin: 0;
font-size: 20px;
font-size: 14px;
font-weight: 600;
background: linear-gradient(135deg, #4fc3f7 0%, #03a9f4 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
color: #4fc3f7;
letter-spacing: 0.5px;
}
h3 {
@@ -386,6 +386,8 @@
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
flex: 1;
}
/* Status Grid */

View File

@@ -106,6 +106,9 @@
padding: 0;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
height: 100%;
display: flex;
flex-direction: column;
}
.card-header {
@@ -143,6 +146,7 @@
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
/* Relays */
@@ -253,6 +257,7 @@
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
flex: 1;
}
.control-btn {