Compare commits
16 Commits
2bec98a080
...
flexradio/
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f2dc76d55 | |||
| 5ced01c010 | |||
| 30688ad644 | |||
| 3e169fe615 | |||
| 21db2addff | |||
| 130efeee83 | |||
| 4eeec6bdf6 | |||
| de3fda2648 | |||
| c6ceeb103b | |||
| b8884d89e3 | |||
| 5332ab9dc1 | |||
| b8db847343 | |||
| 0cb83157de | |||
| 4f484b0091 | |||
| 6b5508802a | |||
| 51e08d9463 |
5
cmd/server/web/dist/index.html
vendored
5
cmd/server/web/dist/index.html
vendored
@@ -7,10 +7,11 @@
|
||||
<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-ghAyyhf_.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-oYZfaWiS.css">
|
||||
<script type="module" crossorigin src="/assets/index-BqlArLJ0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bl7hatTL.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -119,13 +119,35 @@ func (dm *DeviceManager) Initialize() error {
|
||||
dm.flexRadio = flexradio.New(
|
||||
dm.config.Devices.FlexRadio.Host,
|
||||
dm.config.Devices.FlexRadio.Port,
|
||||
dm.config.Devices.FlexRadio.InterlockName,
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// Set callback to check if transmit is allowed (based on Ultrabeam motors)
|
||||
dm.flexRadio.SetTransmitCheckCallback(func() bool {
|
||||
// Get current Ultrabeam status
|
||||
ubStatus, err := dm.ultrabeam.GetStatus()
|
||||
if err != nil || ubStatus == nil {
|
||||
// If we cannot get status, allow transmit (fail-safe)
|
||||
return true
|
||||
}
|
||||
|
||||
// Block transmit if motors are moving
|
||||
motorsMoving := ubStatus.MotorsMoving != 0
|
||||
if motorsMoving {
|
||||
log.Printf("FlexRadio PTT check: Motors moving (bitmask=%d) - BLOCKING", ubStatus.MotorsMoving)
|
||||
} else {
|
||||
log.Printf("FlexRadio PTT check: Motors stopped - ALLOWING")
|
||||
}
|
||||
|
||||
return !motorsMoving
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize Solar data client
|
||||
@@ -402,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
|
||||
|
||||
@@ -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,26 +30,59 @@ type Client struct {
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
|
||||
// Callbacks
|
||||
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
|
||||
}
|
||||
|
||||
func New(host string, port int, interlockName string) *Client {
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -67,8 +101,9 @@ func (c *Client) Connect() error {
|
||||
|
||||
c.conn = conn
|
||||
c.reader = bufio.NewReader(conn)
|
||||
c.reconnectAttempts = 0
|
||||
|
||||
log.Println("FlexRadio: Connected successfully")
|
||||
log.Println("FlexRadio: TCP connection established")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -77,14 +112,16 @@ func (c *Client) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try initial connection
|
||||
if err := c.Connect(); err != nil {
|
||||
return err
|
||||
log.Printf("FlexRadio: Initial connection failed: %v", err)
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -93,11 +130,27 @@ func (c *Client) Start() error {
|
||||
// Start message listener
|
||||
go c.messageLoop()
|
||||
|
||||
// Subscribe to slice updates for frequency tracking
|
||||
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 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
|
||||
@@ -111,6 +164,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()
|
||||
@@ -119,23 +180,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()
|
||||
|
||||
@@ -144,7 +216,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()
|
||||
@@ -154,14 +226,35 @@ func (c *Client) sendCommand(cmd string) (string, error) {
|
||||
|
||||
_, 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()
|
||||
return "", fmt.Errorf("failed to send command: %w", err)
|
||||
|
||||
// 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
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) getNextSeq() int {
|
||||
c.cmdSeqMu.Lock()
|
||||
defer c.cmdSeqMu.Unlock()
|
||||
c.cmdSeq++
|
||||
return c.cmdSeq
|
||||
}
|
||||
|
||||
func (c *Client) messageLoop() {
|
||||
@@ -172,25 +265,20 @@ func (c *Client) messageLoop() {
|
||||
if c.conn == nil || c.reader == nil {
|
||||
c.connMu.Unlock()
|
||||
time.Sleep(1 * time.Second)
|
||||
if err := c.Connect(); err != nil {
|
||||
log.Printf("FlexRadio: Reconnect failed: %v", err)
|
||||
continue
|
||||
}
|
||||
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)
|
||||
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
@@ -199,10 +287,11 @@ func (c *Client) messageLoop() {
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Update connected status
|
||||
c.statusMu.Lock()
|
||||
if c.lastStatus != nil {
|
||||
c.lastStatus.Connected = false
|
||||
c.lastStatus.RadioOn = false
|
||||
c.lastStatus.RadioInfo = "Connection lost"
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
continue
|
||||
@@ -219,71 +308,402 @@ 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") {
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Status format: S<handle>|<key>=<value> ...
|
||||
if strings.HasPrefix(msg, "S") {
|
||||
c.handleStatus(msg)
|
||||
return
|
||||
}
|
||||
// DEBUG: Log tous les messages reçus
|
||||
log.Printf("FlexRadio RAW: %s", msg)
|
||||
|
||||
// Version/handle format: V<version>|H<handle>
|
||||
if strings.HasPrefix(msg, "V") {
|
||||
log.Printf("FlexRadio: Version/Handle received: %s", msg)
|
||||
return
|
||||
}
|
||||
|
||||
// Message format: M<handle>|<message>
|
||||
if strings.HasPrefix(msg, "M") {
|
||||
// Router selon le premier caractère
|
||||
switch msg[0] {
|
||||
case 'R': // Réponse à une commande
|
||||
c.handleCommandResponse(msg)
|
||||
case 'S': // Message de statut
|
||||
c.handleStatusMessage(msg)
|
||||
case 'V': // Version/Handle
|
||||
log.Printf("FlexRadio: Version/Handle: %s", msg)
|
||||
case 'M': // Message général
|
||||
log.Printf("FlexRadio: Message: %s", msg)
|
||||
return
|
||||
default:
|
||||
log.Printf("FlexRadio: Unknown message type: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleStatus(msg string) {
|
||||
// Format: S<handle>|<key>=<value> ...
|
||||
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) handleStatusMessage(msg string) {
|
||||
parts := strings.SplitN(msg, "|", 2)
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
handle := parts[0][1:]
|
||||
data := parts[1]
|
||||
|
||||
// Parse key=value pairs
|
||||
pairs := strings.Fields(data)
|
||||
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 {
|
||||
switch {
|
||||
case strings.Contains(msg, "interlock"):
|
||||
c.handleInterlockStatus(handle, statusMap)
|
||||
|
||||
case strings.Contains(msg, "slice"):
|
||||
c.handleSliceStatus(handle, statusMap)
|
||||
|
||||
case strings.Contains(msg, "radio"):
|
||||
c.handleRadioStatus(handle, statusMap)
|
||||
|
||||
default:
|
||||
log.Printf("FlexRadio: Unknown status (handle=%s): %s", handle, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleInterlockStatus(handle string, statusMap map[string]string) {
|
||||
c.statusMu.Lock()
|
||||
oldFreq := c.lastStatus.Frequency
|
||||
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) handleSliceStatus(handle string, statusMap map[string]string) {
|
||||
c.statusMu.Lock()
|
||||
defer c.statusMu.Unlock()
|
||||
|
||||
// Quand on reçoit un message de slice, on a au moins une slice active
|
||||
c.lastStatus.ActiveSlices = 1
|
||||
|
||||
if rfFreq, ok := statusMap["RF_frequency"]; ok {
|
||||
if freq, err := strconv.ParseFloat(rfFreq, 64); err == nil && freq > 0 {
|
||||
c.lastStatus.Frequency = freq
|
||||
c.statusMu.Unlock()
|
||||
c.lastStatus.RadioInfo = fmt.Sprintf("Active on %.3f MHz", freq)
|
||||
|
||||
// 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)
|
||||
|
||||
// Trigger callback for immediate auto-track
|
||||
if c.onFrequencyChange != nil {
|
||||
go c.onFrequencyChange(freq)
|
||||
}
|
||||
} else if freq == 0 {
|
||||
// Fréquence 0 dans le message de slice = slice inactive
|
||||
c.lastStatus.Frequency = 0
|
||||
c.lastStatus.RadioInfo = "Slice inactive"
|
||||
}
|
||||
}
|
||||
|
||||
if mode, ok := statusMap["mode"]; ok {
|
||||
c.lastStatus.Mode = mode
|
||||
}
|
||||
|
||||
if tx, ok := statusMap["tx"]; ok {
|
||||
c.lastStatus.Tx = (tx == "1")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleRadioStatus(handle string, statusMap map[string]string) {
|
||||
if slices, ok := statusMap["slices"]; ok {
|
||||
if num, err := strconv.Atoi(slices); err == nil {
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus.NumSlices = num
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
c.updateRadioStatus(true, "Radio is on")
|
||||
|
||||
go func() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
c.SendSliceList()
|
||||
}()
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,14 +713,25 @@ 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,
|
||||
RadioInfo: "Not initialized",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Create a copy
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Model string `json:"model"`
|
||||
Serial string `json:"serial"`
|
||||
Version string `json:"version"`
|
||||
Frequency float64 `json:"frequency"`
|
||||
Mode string `json:"mode"`
|
||||
Tx bool `json:"tx"`
|
||||
RadioOn bool `json:"radio_on"` // Radio is powered on and responding
|
||||
RadioInfo string `json:"radio_info"` // Additional info about radio state
|
||||
Callsign string `json:"callsign"` // From info command
|
||||
Model string `json:"model"` // From info command
|
||||
SoftwareVer string `json:"software_ver"` // From info command
|
||||
NumSlices int `json:"num_slices"` // From info command
|
||||
ActiveSlices int `json:"active_slices"` // Count of active slices
|
||||
}
|
||||
|
||||
// InterlockState represents possible interlock states
|
||||
|
||||
@@ -24,6 +24,7 @@ type Client struct {
|
||||
|
||||
type Status struct {
|
||||
Heading int `json:"heading"`
|
||||
TargetHeading int `json:"target_heading"`
|
||||
Connected bool `json:"connected"`
|
||||
}
|
||||
|
||||
@@ -211,6 +212,11 @@ func (c *Client) parseStatus(response string) *Status {
|
||||
if err == nil {
|
||||
status.Heading = heading
|
||||
}
|
||||
targetStr := response[19:22]
|
||||
targetHeading, err := strconv.Atoi(strings.TrimSpace(targetStr))
|
||||
if err == nil {
|
||||
status.TargetHeading = targetHeading
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { wsService, connected, systemStatus } from './lib/websocket.js';
|
||||
import { api } from './lib/api.js';
|
||||
import StatusBanner from './components/StatusBanner.svelte';
|
||||
import WebSwitch from './components/WebSwitch.svelte';
|
||||
import PowerGenius from './components/PowerGenius.svelte';
|
||||
import TunerGenius from './components/TunerGenius.svelte';
|
||||
@@ -13,6 +14,8 @@
|
||||
let isConnected = false;
|
||||
let currentTime = new Date();
|
||||
let callsign = 'F4BPO'; // Default
|
||||
let latitude = null;
|
||||
let longitude = null;
|
||||
|
||||
const unsubscribeStatus = systemStatus.subscribe(value => {
|
||||
status = value;
|
||||
@@ -40,6 +43,10 @@
|
||||
if (config.callsign) {
|
||||
callsign = config.callsign;
|
||||
}
|
||||
if (config.location) {
|
||||
latitude = config.location.latitude;
|
||||
longitude = config.location.longitude;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch config:', err);
|
||||
}
|
||||
@@ -95,10 +102,10 @@
|
||||
|
||||
<div class="header-right">
|
||||
<div class="weather-info">
|
||||
<span title="Wind">🌬️ {weatherData.wind_speed.toFixed(1)}m/s</span>
|
||||
<span title="Gust">💨 {weatherData.wind_gust.toFixed(1)}m/s</span>
|
||||
<span title="Temperature">🌡️ {weatherData.temp.toFixed(1)}°C</span>
|
||||
<span title="Feels like">→ {weatherData.feels_like.toFixed(1)}°C</span>
|
||||
<span title="Wind">🌬️ {weatherData.wind_speed.toFixed(1)} km/h</span>
|
||||
<span title="Gust">💨 {weatherData.wind_gust.toFixed(1)} km/h</span>
|
||||
<span title="Temperature">🌡️ {weatherData.temp.toFixed(1)} °C</span>
|
||||
<span title="Feels like">→ {weatherData.feels_like.toFixed(1)} °C</span>
|
||||
</div>
|
||||
<div class="clock">
|
||||
<span class="time">{formatTime(currentTime)}</span>
|
||||
@@ -107,6 +114,16 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ✅ NOUVEAU : Bandeau de statut avec fréquence FlexRadio et alertes météo -->
|
||||
<StatusBanner
|
||||
flexradio={status?.flexradio}
|
||||
weather={status?.weather}
|
||||
{latitude}
|
||||
{longitude}
|
||||
windWarningThreshold={30}
|
||||
gustWarningThreshold={50}
|
||||
/>
|
||||
|
||||
<main>
|
||||
<div class="dashboard-grid">
|
||||
<div class="row">
|
||||
@@ -132,12 +149,13 @@
|
||||
}
|
||||
|
||||
header {
|
||||
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
||||
padding: 16px 24px;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
padding: 8px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
border-bottom: 1px solid rgba(79, 195, 247, 0.2);
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
@@ -243,6 +261,7 @@
|
||||
.date {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
main {
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
<div class="power-bar-container">
|
||||
<div class="power-bar-bg">
|
||||
<div class="power-bar-fill" style="width: {powerPercent}%">
|
||||
<div class="power-bar-glow"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,16 +281,6 @@
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.power-bar-glow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5));
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
// Update heading with detailed logging to debug
|
||||
$: if (status?.heading !== undefined && status?.heading !== null) {
|
||||
const newHeading = status.heading;
|
||||
const oldHeading = heading;
|
||||
|
||||
if (heading === null) {
|
||||
// First time: accept any value
|
||||
@@ -25,7 +24,6 @@
|
||||
} else {
|
||||
// Normal update
|
||||
heading = newHeading;
|
||||
console.log(` ✓ Updated to ${heading}°`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,32 +32,38 @@
|
||||
|
||||
$: connected = status?.connected || false;
|
||||
|
||||
let targetHeading = 0;
|
||||
let hasTarget = false;
|
||||
// ✅ Target heading from rotator status (when controlled by PST Rotator or other software)
|
||||
$: statusTargetHeading = status?.target_heading ?? null;
|
||||
|
||||
// Clear target when we reach it (within 5 degrees)
|
||||
$: if (hasTarget && heading !== null) {
|
||||
const diff = Math.abs(heading - targetHeading);
|
||||
// Local target (when clicking on map in ShackMaster)
|
||||
let localTargetHeading = null;
|
||||
|
||||
// ✅ Determine if antenna is moving to a target from status
|
||||
// (target differs from current heading by more than 2 degrees)
|
||||
$: isMovingFromStatus = statusTargetHeading !== null &&
|
||||
heading !== null &&
|
||||
(() => {
|
||||
const diff = Math.abs(statusTargetHeading - heading);
|
||||
const wrappedDiff = Math.min(diff, 360 - diff);
|
||||
if (wrappedDiff < 5) {
|
||||
hasTarget = false;
|
||||
}
|
||||
}
|
||||
return wrappedDiff > 2;
|
||||
})();
|
||||
|
||||
async function goToHeading() {
|
||||
if (targetHeading < 0 || targetHeading > 359) {
|
||||
// Removed alert popup - check console for errors
|
||||
return;
|
||||
}
|
||||
try {
|
||||
hasTarget = true; // Mark that we have a target
|
||||
// Subtract 10 degrees to compensate for rotator momentum
|
||||
const adjustedHeading = (targetHeading + 360) % 360;
|
||||
await api.rotator.setHeading(adjustedHeading);
|
||||
} catch (err) {
|
||||
console.error('Failed to set heading:', err);
|
||||
hasTarget = false;
|
||||
// Removed alert popup - check console for errors
|
||||
// ✅ Active target: prefer status target when moving, otherwise use local target
|
||||
$: activeTargetHeading = localTargetHeading ?? (isMovingFromStatus ? statusTargetHeading : null);
|
||||
|
||||
// ✅ Has target if there's an active target that differs from current heading
|
||||
$: hasTarget = activeTargetHeading !== null && heading !== null && (() => {
|
||||
const diff = Math.abs(activeTargetHeading - heading);
|
||||
const wrappedDiff = Math.min(diff, 360 - diff);
|
||||
return wrappedDiff > 2;
|
||||
})();
|
||||
|
||||
// Clear local target when we reach it (within 3 degrees)
|
||||
$: if (localTargetHeading !== null && heading !== null) {
|
||||
const diff = Math.abs(heading - localTargetHeading);
|
||||
const wrappedDiff = Math.min(diff, 360 - diff);
|
||||
if (wrappedDiff < 3) {
|
||||
localTargetHeading = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +85,7 @@
|
||||
|
||||
async function stop() {
|
||||
try {
|
||||
localTargetHeading = null; // Clear local target on stop
|
||||
await api.rotator.stop();
|
||||
} catch (err) {
|
||||
console.error('Failed to stop:', err);
|
||||
@@ -88,7 +93,7 @@
|
||||
}
|
||||
|
||||
// Handle click on compass to set heading
|
||||
function handleCompassClick(event) {
|
||||
async function handleCompassClick(event) {
|
||||
const svg = event.currentTarget;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const centerX = rect.width / 2;
|
||||
@@ -104,10 +109,16 @@
|
||||
|
||||
// Round to nearest 5 degrees
|
||||
const roundedHeading = Math.round(angle / 5) * 5;
|
||||
const adjustedHeading = (roundedHeading + 360) % 360;
|
||||
|
||||
// Set target and go
|
||||
targetHeading = roundedHeading;
|
||||
goToHeading();
|
||||
// ✅ CORRIGÉ : Send command first, then set localTargetHeading only on success
|
||||
try {
|
||||
await api.rotator.setHeading(adjustedHeading);
|
||||
// Only set local target AFTER successful API call
|
||||
localTargetHeading = adjustedHeading;
|
||||
} catch (err) {
|
||||
console.error('Failed to set heading:', err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -124,8 +135,8 @@
|
||||
<div class="heading-label">CURRENT HEADING</div>
|
||||
<div class="heading-value">
|
||||
{displayHeading}°
|
||||
{#if hasTarget}
|
||||
<span class="target-indicator">→ {targetHeading}°</span>
|
||||
{#if hasTarget && activeTargetHeading !== null}
|
||||
<span class="target-indicator">→ {activeTargetHeading}°</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,19 +293,21 @@
|
||||
|
||||
</g>
|
||||
|
||||
<!-- Target arrow (if we have a target) -->
|
||||
{#if hasTarget}
|
||||
<g transform="rotate({targetHeading})">
|
||||
<line x1="0" y1="0" x2="0" y2="-120"
|
||||
<!-- ✅ Target arrow (yellow) - shown when antenna is moving to target -->
|
||||
{#if hasTarget && activeTargetHeading !== null}
|
||||
<g transform="rotate({activeTargetHeading})">
|
||||
<!-- Target direction line (dashed yellow) -->
|
||||
<line x1="0" y1="0" x2="0" y2="-135"
|
||||
stroke="#ffc107"
|
||||
stroke-width="3"
|
||||
stroke-dasharray="8,4"
|
||||
opacity="0.9"/>
|
||||
<g transform="translate(0, -120)">
|
||||
<polygon points="0,-15 -10,10 0,5 10,10"
|
||||
<!-- Target arrow head with pulse animation -->
|
||||
<g transform="translate(0, -135)">
|
||||
<polygon points="0,-12 -8,6 0,2 8,6"
|
||||
fill="#ffc107"
|
||||
stroke="#ff9800"
|
||||
stroke-width="2"
|
||||
stroke-width="1.5"
|
||||
style="filter: drop-shadow(0 0 10px rgba(255, 193, 7, 0.8))">
|
||||
<animate attributeName="opacity" values="0.8;1;0.8" dur="1s" repeatCount="indefinite"/>
|
||||
</polygon>
|
||||
@@ -302,7 +315,7 @@
|
||||
</g>
|
||||
{/if}
|
||||
|
||||
<!-- Center dot (your QTH - JN36dg) -->
|
||||
<!-- Center dot (your QTH) -->
|
||||
<circle cx="0" cy="0" r="5" fill="#f44336" stroke="#fff" stroke-width="2">
|
||||
<animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
@@ -344,8 +357,6 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Go To Heading -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -396,8 +407,6 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Heading Display */
|
||||
|
||||
.heading-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -486,7 +495,6 @@
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Map */
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -522,7 +530,7 @@
|
||||
.clickable-compass {
|
||||
cursor: crosshair;
|
||||
user-select: none;
|
||||
outline: none; /* Remove focus outline */
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.clickable-compass:hover {
|
||||
@@ -541,5 +549,4 @@
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
</style>
|
||||
741
web/src/components/StatusBanner.svelte
Normal file
741
web/src/components/StatusBanner.svelte
Normal file
@@ -0,0 +1,741 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
export let flexradio = null;
|
||||
export let weather = null;
|
||||
export let latitude = null;
|
||||
export let longitude = null;
|
||||
export let windWarningThreshold = 30; // km/h
|
||||
export let gustWarningThreshold = 50; // km/h
|
||||
export let graylineWindow = 30; // minutes avant/après sunrise/sunset
|
||||
|
||||
// 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;
|
||||
let sunset = null;
|
||||
let isGrayline = false;
|
||||
let graylineType = ''; // 'sunrise' ou 'sunset'
|
||||
let timeToNextEvent = '';
|
||||
let currentTime = new Date();
|
||||
let clockInterval;
|
||||
|
||||
// Update time every minute for grayline check
|
||||
onMount(() => {
|
||||
calculateSunTimes();
|
||||
clockInterval = setInterval(() => {
|
||||
currentTime = new Date();
|
||||
checkGrayline();
|
||||
updateTimeToNextEvent();
|
||||
}, 10000); // Update every 10 seconds
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (clockInterval) clearInterval(clockInterval);
|
||||
});
|
||||
|
||||
// Recalculate when location changes
|
||||
$: if (latitude && longitude) {
|
||||
calculateSunTimes();
|
||||
}
|
||||
|
||||
// SunCalc algorithm (simplified version)
|
||||
function calculateSunTimes() {
|
||||
if (!latitude || !longitude) return;
|
||||
|
||||
const now = new Date();
|
||||
const times = getSunTimes(now, latitude, longitude);
|
||||
sunrise = times.sunrise;
|
||||
sunset = times.sunset;
|
||||
checkGrayline();
|
||||
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;
|
||||
const dayOfYear = getDayOfYear(date);
|
||||
|
||||
// Fractional year
|
||||
const gamma = (2 * Math.PI / 365) * (dayOfYear - 1 + (date.getHours() - 12) / 24);
|
||||
|
||||
// Equation of time (minutes)
|
||||
const eqTime = 229.18 * (0.000075 + 0.001868 * Math.cos(gamma) - 0.032077 * Math.sin(gamma)
|
||||
- 0.014615 * Math.cos(2 * gamma) - 0.040849 * Math.sin(2 * gamma));
|
||||
|
||||
// Solar declination (radians)
|
||||
const decl = 0.006918 - 0.399912 * Math.cos(gamma) + 0.070257 * Math.sin(gamma)
|
||||
- 0.006758 * Math.cos(2 * gamma) + 0.000907 * Math.sin(2 * gamma)
|
||||
- 0.002697 * Math.cos(3 * gamma) + 0.00148 * Math.sin(3 * gamma);
|
||||
|
||||
// Hour angle for sunrise/sunset
|
||||
const latRad = lat * rad;
|
||||
const zenith = 90.833 * rad; // Official zenith for sunrise/sunset
|
||||
|
||||
const cosHA = (Math.cos(zenith) / (Math.cos(latRad) * Math.cos(decl)))
|
||||
- Math.tan(latRad) * Math.tan(decl);
|
||||
|
||||
// Check for polar day/night
|
||||
if (cosHA > 1 || cosHA < -1) {
|
||||
return { sunrise: null, sunset: null };
|
||||
}
|
||||
|
||||
const ha = Math.acos(cosHA) / rad; // Hour angle in degrees
|
||||
|
||||
// Sunrise and sunset times in minutes from midnight UTC
|
||||
const sunriseMinutes = 720 - 4 * (lon + ha) - eqTime;
|
||||
const sunsetMinutes = 720 - 4 * (lon - ha) - eqTime;
|
||||
|
||||
// Convert to local Date objects
|
||||
const sunriseDate = new Date(date);
|
||||
sunriseDate.setUTCHours(0, 0, 0, 0);
|
||||
sunriseDate.setUTCMinutes(sunriseMinutes);
|
||||
|
||||
const sunsetDate = new Date(date);
|
||||
sunsetDate.setUTCHours(0, 0, 0, 0);
|
||||
sunsetDate.setUTCMinutes(sunsetMinutes);
|
||||
|
||||
return { sunrise: sunriseDate, sunset: sunsetDate };
|
||||
}
|
||||
|
||||
function getDayOfYear(date) {
|
||||
const start = new Date(date.getFullYear(), 0, 0);
|
||||
const diff = date - start;
|
||||
const oneDay = 1000 * 60 * 60 * 24;
|
||||
return Math.floor(diff / oneDay);
|
||||
}
|
||||
|
||||
function checkGrayline() {
|
||||
if (!sunrise || !sunset) {
|
||||
isGrayline = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = currentTime.getTime();
|
||||
const windowMs = graylineWindow * 60 * 1000;
|
||||
|
||||
const nearSunrise = Math.abs(now - sunrise.getTime()) <= windowMs;
|
||||
const nearSunset = Math.abs(now - sunset.getTime()) <= windowMs;
|
||||
|
||||
isGrayline = nearSunrise || nearSunset;
|
||||
graylineType = nearSunrise ? 'sunrise' : (nearSunset ? 'sunset' : '');
|
||||
}
|
||||
|
||||
function updateTimeToNextEvent() {
|
||||
if (!sunrise || !sunset) {
|
||||
timeToNextEvent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const now = currentTime.getTime();
|
||||
let nextEvent = null;
|
||||
let eventName = '';
|
||||
|
||||
if (now < sunrise.getTime()) {
|
||||
nextEvent = sunrise;
|
||||
eventName = 'Sunrise';
|
||||
} else if (now < sunset.getTime()) {
|
||||
nextEvent = sunset;
|
||||
eventName = 'Sunset';
|
||||
} else {
|
||||
// After sunset, calculate tomorrow's sunrise
|
||||
const tomorrow = new Date(currentTime);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const tomorrowTimes = getSunTimes(tomorrow, latitude, longitude);
|
||||
nextEvent = tomorrowTimes.sunrise;
|
||||
eventName = 'Sunrise';
|
||||
}
|
||||
|
||||
if (nextEvent) {
|
||||
const diffMs = nextEvent.getTime() - now;
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 0) {
|
||||
timeToNextEvent = `${eventName} in ${hours}h${minutes.toString().padStart(2, '0')}m`;
|
||||
} else {
|
||||
timeToNextEvent = `${eventName} in ${minutes}m`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(date) {
|
||||
if (!date) return '--:--';
|
||||
return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Format frequency for display (MHz with appropriate decimals)
|
||||
function formatFrequency(freqMHz) {
|
||||
if (!freqMHz || freqMHz === 0) return '---';
|
||||
if (freqMHz < 10) {
|
||||
return freqMHz.toFixed(4);
|
||||
} else if (freqMHz < 100) {
|
||||
return freqMHz.toFixed(3);
|
||||
} else {
|
||||
return freqMHz.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Get band from frequency
|
||||
function getBand(freqMHz) {
|
||||
if (!freqMHz || freqMHz === 0) return '';
|
||||
if (freqMHz >= 1.8 && freqMHz <= 2.0) return '160M';
|
||||
if (freqMHz >= 3.5 && freqMHz <= 4.0) return '80M';
|
||||
if (freqMHz >= 5.3 && freqMHz <= 5.4) return '60M';
|
||||
if (freqMHz >= 7.0 && freqMHz <= 7.3) return '40M';
|
||||
if (freqMHz >= 10.1 && freqMHz <= 10.15) return '30M';
|
||||
if (freqMHz >= 14.0 && freqMHz <= 14.35) return '20M';
|
||||
if (freqMHz >= 18.068 && freqMHz <= 18.168) return '17M';
|
||||
if (freqMHz >= 21.0 && freqMHz <= 21.45) return '15M';
|
||||
if (freqMHz >= 24.89 && freqMHz <= 24.99) return '12M';
|
||||
if (freqMHz >= 28.0 && freqMHz <= 29.7) return '10M';
|
||||
if (freqMHz >= 50.0 && freqMHz <= 54.0) return '6M';
|
||||
if (freqMHz >= 144.0 && freqMHz <= 148.0) return '2M';
|
||||
if (freqMHz >= 430.0 && freqMHz <= 440.0) return '70CM';
|
||||
return '';
|
||||
}
|
||||
|
||||
// Weather alerts
|
||||
$: windSpeed = weather?.wind_speed || 0;
|
||||
$: windGust = weather?.wind_gust || 0;
|
||||
$: hasWindWarning = windSpeed >= windWarningThreshold;
|
||||
$: hasGustWarning = windGust >= gustWarningThreshold;
|
||||
$: hasAnyWarning = hasWindWarning || hasGustWarning;
|
||||
|
||||
// Band colors
|
||||
function getBandColor(band) {
|
||||
const colors = {
|
||||
'160M': '#9c27b0',
|
||||
'80M': '#673ab7',
|
||||
'60M': '#3f51b5',
|
||||
'40M': '#2196f3',
|
||||
'30M': '#00bcd4',
|
||||
'20M': '#009688',
|
||||
'17M': '#4caf50',
|
||||
'15M': '#8bc34a',
|
||||
'12M': '#cddc39',
|
||||
'10M': '#ffeb3b',
|
||||
'6M': '#ff9800',
|
||||
'2M': '#ff5722',
|
||||
'70CM': '#f44336'
|
||||
};
|
||||
return colors[band] || '#4fc3f7';
|
||||
}
|
||||
|
||||
$: 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}>
|
||||
<!-- FlexRadio Section -->
|
||||
<div class="flex-section">
|
||||
<div class="flex-icon" class:connected={connected} class:disconnected={!connected}>
|
||||
📻
|
||||
</div>
|
||||
|
||||
{#if showFrequency}
|
||||
<!-- Radio is on and has active slice with frequency -->
|
||||
<div class="frequency-display">
|
||||
<span class="frequency" style="--band-color: {bandColor}">
|
||||
{formatFrequency(frequency)}
|
||||
</span>
|
||||
<span class="unit">MHz</span>
|
||||
</div>
|
||||
|
||||
{#if currentBand}
|
||||
<span class="band-badge" style="background-color: {bandColor}">
|
||||
{currentBand}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if mode}
|
||||
<span class="mode-badge">
|
||||
{mode}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- AFFICHAGE TX - SEULEMENT SI TX EST VRAI -->
|
||||
{#if txEnabled}
|
||||
<span class="tx-indicator">
|
||||
TX
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{: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>
|
||||
|
||||
<!-- Grayline Section -->
|
||||
<div class="grayline-section">
|
||||
{#if latitude && longitude}
|
||||
<div class="sun-times">
|
||||
<span class="sun-item" title="Sunrise">
|
||||
<svg class="sun-icon sunrise-icon" width="18" height="18" viewBox="0 0 24 24">
|
||||
<!-- Horizon line -->
|
||||
<line x1="2" y1="18" x2="22" y2="18" stroke="currentColor" stroke-width="1.5" opacity="0.5"/>
|
||||
<!-- Sun (half visible) -->
|
||||
<path d="M12 18 A6 6 0 0 1 12 6 A6 6 0 0 1 12 18" fill="#fbbf24"/>
|
||||
<!-- Rays -->
|
||||
<path d="M12 2v3M5.6 5.6l2.1 2.1M2 12h3M19 12h3M18.4 5.6l-2.1 2.1" stroke="#fbbf24" stroke-width="2" stroke-linecap="round"/>
|
||||
<!-- Up arrow -->
|
||||
<path d="M12 14l-3 3M12 14l3 3M12 14v5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
{formatTime(sunrise)}
|
||||
</span>
|
||||
<span class="sun-item" title="Sunset">
|
||||
<svg class="sun-icon sunset-icon" width="18" height="18" viewBox="0 0 24 24">
|
||||
<!-- Horizon line -->
|
||||
<line x1="2" y1="18" x2="22" y2="18" stroke="currentColor" stroke-width="1.5" opacity="0.5"/>
|
||||
<!-- Sun (half visible, setting) -->
|
||||
<path d="M12 18 A6 6 0 0 1 12 6 A6 6 0 0 1 12 18" fill="#f97316"/>
|
||||
<!-- Rays (dimmer) -->
|
||||
<path d="M12 2v3M5.6 5.6l2.1 2.1M2 12h3M19 12h3M18.4 5.6l-2.1 2.1" stroke="#f97316" stroke-width="2" stroke-linecap="round" opacity="0.7"/>
|
||||
<!-- Down arrow -->
|
||||
<path d="M12 22l-3-3M12 22l3-3M12 22v-5" stroke="#ef4444" stroke-width="2" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
{formatTime(sunset)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if isGrayline}
|
||||
<span class="grayline-badge" class:sunrise={graylineType === 'sunrise'} class:sunset={graylineType === 'sunset'}>
|
||||
✨ Grayline
|
||||
</span>
|
||||
{:else if timeToNextEvent}
|
||||
<span class="next-event">
|
||||
{timeToNextEvent}
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="no-location">📍 Position not set</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="separator"></div>
|
||||
|
||||
<!-- Weather Alerts Section -->
|
||||
<div class="weather-section">
|
||||
{#if hasWindWarning}
|
||||
<div class="alert wind-alert">
|
||||
<span class="alert-icon">⚠️</span>
|
||||
<span class="alert-text">
|
||||
Vent: <strong>{windSpeed.toFixed(0)} km/h</strong>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasGustWarning}
|
||||
<div class="alert gust-alert">
|
||||
<span class="alert-icon">🌪️</span>
|
||||
<span class="alert-text">
|
||||
Rafales: <strong>{windGust.toFixed(0)} km/h</strong>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !hasAnyWarning}
|
||||
<div class="status-ok">
|
||||
<span class="ok-icon">✓</span>
|
||||
<span class="ok-text">Weather OK</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.status-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 24px;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
border-bottom: 1px solid rgba(79, 195, 247, 0.15);
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-banner.has-warning {
|
||||
background: linear-gradient(135deg, #1f1a12 0%, #29201b 50%, #2d1b0c 100%);
|
||||
border-bottom-color: #f59e0b;
|
||||
}
|
||||
|
||||
/* FlexRadio Section */
|
||||
.flex-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.flex-icon {
|
||||
font-size: 20px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.flex-icon.connected {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 4px rgba(79, 195, 247, 0.6));
|
||||
}
|
||||
|
||||
.flex-icon.disconnected {
|
||||
opacity: 0.4;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.frequency-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.frequency {
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
font-family: 'Roboto Mono', 'Consolas', monospace;
|
||||
color: var(--band-color, #4fc3f7);
|
||||
text-shadow: 0 0 15px var(--band-color, rgba(79, 195, 247, 0.5));
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.band-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.mode-badge {
|
||||
padding: 4px 10px;
|
||||
background: rgba(156, 39, 176, 0.3);
|
||||
border: 1px solid rgba(156, 39, 176, 0.5);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #ce93d8;
|
||||
}
|
||||
|
||||
.tx-indicator {
|
||||
padding: 4px 10px;
|
||||
background: rgba(244, 67, 54, 0.3);
|
||||
border: 1px solid #f44336;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #f44336;
|
||||
text-shadow: 0 0 10px rgba(244, 67, 54, 0.8);
|
||||
animation: txPulse 0.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes txPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.slice-waiting {
|
||||
color: #fbbf24; /* Jaune pour "en attente" */
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Separator */
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 30px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Grayline Section */
|
||||
.grayline-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sun-times {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sun-item {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sun-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sunrise-icon {
|
||||
color: rgba(251, 191, 36, 0.6);
|
||||
filter: drop-shadow(0 0 4px rgba(251, 191, 36, 0.4));
|
||||
}
|
||||
|
||||
.sunset-icon {
|
||||
color: rgba(249, 115, 22, 0.6);
|
||||
filter: drop-shadow(0 0 4px rgba(249, 115, 22, 0.4));
|
||||
}
|
||||
|
||||
.grayline-badge {
|
||||
padding: 5px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
animation: graylinePulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.grayline-badge.sunrise {
|
||||
background: linear-gradient(135deg, rgba(255, 183, 77, 0.3) 0%, rgba(255, 138, 101, 0.3) 100%);
|
||||
border: 1px solid rgba(255, 183, 77, 0.6);
|
||||
color: #ffcc80;
|
||||
text-shadow: 0 0 10px rgba(255, 183, 77, 0.8);
|
||||
}
|
||||
|
||||
.grayline-badge.sunset {
|
||||
background: linear-gradient(135deg, rgba(255, 138, 101, 0.3) 0%, rgba(239, 83, 80, 0.3) 100%);
|
||||
border: 1px solid rgba(255, 138, 101, 0.6);
|
||||
color: #ffab91;
|
||||
text-shadow: 0 0 10px rgba(255, 138, 101, 0.8);
|
||||
}
|
||||
|
||||
@keyframes graylinePulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.85; transform: scale(1.02); }
|
||||
}
|
||||
|
||||
.next-event {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.no-location {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Weather Section */
|
||||
.weather-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
animation: alertPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wind-alert {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
border: 1px solid rgba(245, 158, 11, 0.5);
|
||||
}
|
||||
|
||||
.gust-alert {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border: 1px solid rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
@keyframes alertPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.alert-text {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.alert-text strong {
|
||||
color: #fbbf24;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gust-alert .alert-text strong {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.status-ok {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ok-icon {
|
||||
color: #22c55e;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ok-text {
|
||||
font-size: 13px;
|
||||
color: rgba(34, 197, 94, 0.9);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.status-banner {
|
||||
padding: 8px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.frequency {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.flex-section,
|
||||
.grayline-section,
|
||||
.weather-section {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -67,7 +67,6 @@
|
||||
<div class="power-bar-container">
|
||||
<div class="power-bar-bg">
|
||||
<div class="power-bar-fill" style="width: {powerPercent}%">
|
||||
<div class="power-bar-glow"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,16 +264,6 @@
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.power-bar-glow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5));
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
|
||||
@@ -19,6 +19,16 @@
|
||||
$: interlockState = flexradio?.interlock_state || null;
|
||||
$: interlockColor = getInterlockColor(interlockState);
|
||||
|
||||
// Debug log
|
||||
$: if (flexradio) {
|
||||
console.log('FlexRadio data:', {
|
||||
connected: flexradio.connected,
|
||||
interlock_state: flexradio.interlock_state,
|
||||
interlockConnected,
|
||||
interlockState
|
||||
});
|
||||
}
|
||||
|
||||
function getInterlockColor(state) {
|
||||
switch(state) {
|
||||
case 'READY': return '#4caf50';
|
||||
@@ -154,14 +164,6 @@
|
||||
<div class="card-header">
|
||||
<h2>Ultrabeam VL2.3</h2>
|
||||
<div class="header-right">
|
||||
{#if interlockConnected && interlockState}
|
||||
<div class="interlock-badge" style="border-color: {interlockColor}; color: {interlockColor}">
|
||||
{interlockState === 'READY' ? '🔓 TX OK' :
|
||||
interlockState === 'NOT_READY' ? '🔒 TX Block' :
|
||||
interlockState === 'PTT_REQUESTED' ? '⏳ PTT' :
|
||||
interlockState === 'TRANSMITTING' ? '📡 TX' : '❓'}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="status-dot" class:disconnected={!connected}></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -191,7 +193,7 @@
|
||||
<div class="auto-track-controls">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" bind:checked={autoTrackEnabled} on:change={updateAutoTrack} />
|
||||
<span>Enable Auto-Track from Radio</span>
|
||||
<span>Enable Auto-Track from Tuner</span>
|
||||
</label>
|
||||
|
||||
<div class="threshold-group">
|
||||
@@ -343,18 +345,6 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.interlock-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
|
||||
Reference in New Issue
Block a user