This commit is contained in:
2026-01-10 16:04:38 +01:00
parent f172678560
commit 0ce18d87bc
13 changed files with 779 additions and 226 deletions

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,8 +7,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <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-8_72Rq0c.js"></script> <script type="module" crossorigin src="/assets/index-DIrlWzGj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ml--d1Bc.css"> <link rel="stylesheet" crossorigin href="/assets/index-DvnnYzjx.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -34,6 +34,13 @@ type DeviceManager struct {
updateInterval time.Duration updateInterval time.Duration
stopChan chan struct{} stopChan chan struct{}
// Auto frequency tracking
freqThreshold int // Threshold for triggering update (Hz)
autoTrackEnabled bool
ultrabeamDirection int // User-selected direction (0=normal, 1=180, 2=bi-dir)
lastFreqUpdateTime time.Time // Last time we sent frequency update
freqUpdateCooldown time.Duration // Minimum time between updates
} }
type SystemStatus struct { type SystemStatus struct {
@@ -50,10 +57,14 @@ type SystemStatus struct {
func NewDeviceManager(cfg *config.Config, hub *Hub) *DeviceManager { func NewDeviceManager(cfg *config.Config, hub *Hub) *DeviceManager {
return &DeviceManager{ return &DeviceManager{
config: cfg, config: cfg,
hub: hub, hub: hub,
updateInterval: 1 * time.Second, // Update status every second updateInterval: 1 * time.Second, // Update status every second
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
freqThreshold: 25000, // 25 kHz default
autoTrackEnabled: true, // Enabled by default
ultrabeamDirection: 0, // Normal direction by default
freqUpdateCooldown: 2 * time.Second, // Wait 2 seconds between updates
} }
} }
@@ -232,10 +243,68 @@ func (dm *DeviceManager) updateStatus() {
// Ultrabeam // Ultrabeam
if ubStatus, err := dm.ultrabeam.GetStatus(); err == nil { if ubStatus, err := dm.ultrabeam.GetStatus(); err == nil {
status.Ultrabeam = ubStatus status.Ultrabeam = ubStatus
// Sync direction with Ultrabeam if not yet set (first time or after restart)
// This prevents auto-track from using wrong direction before user changes it
if dm.ultrabeamDirection == 0 && ubStatus.Direction != 0 {
dm.ultrabeamDirection = ubStatus.Direction
log.Printf("Auto-track: Initialized direction from Ultrabeam: %d", dm.ultrabeamDirection)
}
} else { } else {
log.Printf("Ultrabeam error: %v", err) log.Printf("Ultrabeam error: %v", err)
} }
// Auto frequency tracking: Update Ultrabeam when TunerGenius frequency differs from Ultrabeam
if dm.autoTrackEnabled && status.TunerGenius != nil && status.TunerGenius.Connected && status.Ultrabeam != nil && status.Ultrabeam.Connected {
tunerFreqKhz := int(status.TunerGenius.FreqA) // TunerGenius frequency is already in kHz
ultrabeamFreqKhz := status.Ultrabeam.Frequency // Ultrabeam frequency in kHz
// Ignore invalid frequencies or out of Ultrabeam range (40M-6M)
// This prevents retraction when slice is closed (FreqA becomes 0)
// Ultrabeam VL2.3 only covers 7000-54000 kHz (40M to 6M)
if tunerFreqKhz < 7000 || tunerFreqKhz > 54000 {
return // Out of range, skip auto-track
}
freqDiff := tunerFreqKhz - ultrabeamFreqKhz
if freqDiff < 0 {
freqDiff = -freqDiff
}
// Convert diff to Hz for comparison with threshold (which is in Hz)
freqDiffHz := freqDiff * 1000
// Don't send command if motors are already moving
if status.Ultrabeam.MotorsMoving != 0 {
// Motors moving - wait for them to finish
return
}
if freqDiffHz >= dm.freqThreshold {
// Use current Ultrabeam direction if user hasn't explicitly set one
directionToUse := dm.ultrabeamDirection
if directionToUse == 0 && status.Ultrabeam.Direction != 0 {
directionToUse = status.Ultrabeam.Direction
}
// 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 {
log.Printf("Auto-track: Frequency differs by %d kHz, updating Ultrabeam to %d kHz (direction=%d)", freqDiff, tunerFreqKhz, directionToUse)
// Send to Ultrabeam with saved or current direction
if err := dm.ultrabeam.SetFrequency(tunerFreqKhz, directionToUse); err != nil {
log.Printf("Auto-track: Failed to update Ultrabeam: %v (will retry)", err)
} else {
log.Printf("Auto-track: Successfully sent frequency to Ultrabeam")
dm.lastFreqUpdateTime = time.Now() // Update cooldown timer
}
}
}
}
// Solar Data (fetched every 15 minutes, cached) // Solar Data (fetched every 15 minutes, cached)
if solarData, err := dm.solarClient.GetSolarData(); err == nil { if solarData, err := dm.solarClient.GetSolarData(); err == nil {
status.Solar = solarData status.Solar = solarData
@@ -298,3 +367,13 @@ func (dm *DeviceManager) RotatorGenius() *rotatorgenius.Client {
func (dm *DeviceManager) Ultrabeam() *ultrabeam.Client { func (dm *DeviceManager) Ultrabeam() *ultrabeam.Client {
return dm.ultrabeam return dm.ultrabeam
} }
func (dm *DeviceManager) SetAutoTrack(enabled bool, thresholdHz int) {
dm.autoTrackEnabled = enabled
dm.freqThreshold = thresholdHz
}
func (dm *DeviceManager) SetUltrabeamDirection(direction int) {
dm.ultrabeamDirection = direction
log.Printf("Ultrabeam direction set to: %d", direction)
}

View File

@@ -57,6 +57,7 @@ func (s *Server) SetupRoutes() *http.ServeMux {
// Ultrabeam endpoints // Ultrabeam endpoints
mux.HandleFunc("/api/ultrabeam/frequency", s.handleUltrabeamFrequency) mux.HandleFunc("/api/ultrabeam/frequency", s.handleUltrabeamFrequency)
mux.HandleFunc("/api/ultrabeam/retract", s.handleUltrabeamRetract) mux.HandleFunc("/api/ultrabeam/retract", s.handleUltrabeamRetract)
mux.HandleFunc("/api/ultrabeam/autotrack", s.handleUltrabeamAutoTrack)
// Tuner endpoints // Tuner endpoints
mux.HandleFunc("/api/tuner/operate", s.handleTunerOperate) mux.HandleFunc("/api/tuner/operate", s.handleTunerOperate)
@@ -435,6 +436,27 @@ func (s *Server) handleUltrabeamRetract(w http.ResponseWriter, r *http.Request)
s.sendJSON(w, map[string]string{"status": "ok"}) s.sendJSON(w, map[string]string{"status": "ok"})
} }
func (s *Server) handleUltrabeamAutoTrack(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Enabled bool `json:"enabled"`
Threshold int `json:"threshold"` // kHz
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
s.deviceManager.SetAutoTrack(req.Enabled, req.Threshold*1000) // Convert kHz to Hz
s.sendJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) sendJSON(w http.ResponseWriter, data interface{}) { func (s *Server) sendJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data) json.NewEncoder(w).Encode(data)

View File

@@ -58,8 +58,8 @@ func New(host string, port int) *Client {
host: host, host: host,
port: port, port: port,
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
autoFanEnabled: true, // Auto fan management enabled by default autoFanEnabled: false, // Auto fan DISABLED - manual control only
lastFanMode: "Contest", // Default to Contest mode lastFanMode: "Contest",
} }
} }

View File

@@ -103,17 +103,20 @@ func (c *Client) Stop() {
} }
func (c *Client) pollLoop() { func (c *Client) pollLoop() {
ticker := time.NewTicker(500 * time.Millisecond) ticker := time.NewTicker(2 * time.Second) // Increased from 500ms to 2s
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
// Try to connect if not connected // Try to connect if not connected
c.connMu.Lock() c.connMu.Lock()
if c.conn == nil { if c.conn == nil {
log.Printf("Ultrabeam: Not connected, attempting connection...")
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second) conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
if err != nil { if err != nil {
log.Printf("Ultrabeam: Connection failed: %v", err)
c.connMu.Unlock() c.connMu.Unlock()
// Mark as disconnected // Mark as disconnected
@@ -151,12 +154,6 @@ func (c *Client) pollLoop() {
// Mark as connected // Mark as connected
status.Connected = true status.Connected = true
// Query element lengths
lengths, err := c.queryElementLengths()
if err == nil {
status.ElementLengths = lengths
}
// Query progress if motors moving // Query progress if motors moving
if status.MotorsMoving != 0 { if status.MotorsMoving != 0 {
progress, err := c.queryProgress() progress, err := c.queryProgress()
@@ -312,7 +309,7 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
} }
// Read reply with timeout // Read reply with timeout
c.conn.SetReadDeadline(time.Now().Add(2 * time.Second)) c.conn.SetReadDeadline(time.Now().Add(1 * time.Second)) // Reduced from 2s to 1s
// Read until we get a complete packet // Read until we get a complete packet
var buffer []byte var buffer []byte
@@ -341,6 +338,11 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
return nil, fmt.Errorf("failed to parse reply: %w", err) return nil, fmt.Errorf("failed to parse reply: %w", err)
} }
// Log for debugging unknown codes
if replyCmd != UB_OK && replyCmd != UB_BAD && replyCmd != UB_PAR && replyCmd != UB_ERR {
log.Printf("Ultrabeam: Unknown reply code %d (0x%02X), raw packet: %v", replyCmd, replyCmd, buffer)
}
// Check for errors // Check for errors
switch replyCmd { switch replyCmd {
case UB_BAD: case UB_BAD:
@@ -352,7 +354,10 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
case UB_OK: case UB_OK:
return payload, nil return payload, nil
default: default:
return nil, fmt.Errorf("unknown reply code: %d", replyCmd) // Unknown codes might indicate "busy" or "in progress"
// Treat as non-fatal, return empty payload
log.Printf("Ultrabeam: Unusual reply code %d, treating as busy/in-progress", replyCmd)
return []byte{}, nil
} }
} }
@@ -390,17 +395,59 @@ func (c *Client) queryElementLengths() ([]int, error) {
return nil, err return nil, err
} }
// Debug: log raw bytes
log.Printf("Ultrabeam element lengths raw reply (%d bytes): %v", len(reply), reply)
// Try to extract 6 words - the protocol says 6 words (12 bytes)
// But we're receiving 14 bytes, so there might be padding
if len(reply) < 12 { if len(reply) < 12 {
return nil, fmt.Errorf("element lengths reply too short") return nil, fmt.Errorf("element lengths reply too short: %d bytes", len(reply))
} }
lengths := make([]int, 6) lengths := make([]int, 6)
// Try different interpretations
log.Printf("=== Attempting different parsings ===")
// Method 1: Standard little-endian from byte 0
log.Printf("Method 1 (little-endian from 0):")
for i := 0; i < 6 && i*2+1 < len(reply); i++ {
lo := int(reply[i*2])
hi := int(reply[i*2+1])
val := lo | (hi << 8)
log.Printf(" Element %d: bytes[%d,%d] = [%d,%d] => %d mm", i, i*2, i*2+1, lo, hi, val)
}
// Method 2: Big-endian from byte 0
log.Printf("Method 2 (big-endian from 0):")
for i := 0; i < 6 && i*2+1 < len(reply); i++ {
hi := int(reply[i*2])
lo := int(reply[i*2+1])
val := lo | (hi << 8)
log.Printf(" Element %d: bytes[%d,%d] = [%d,%d] => %d mm", i, i*2, i*2+1, hi, lo, val)
}
// Method 3: Skip first 2 bytes, then little-endian
log.Printf("Method 3 (skip 2 bytes, little-endian):")
for i := 0; i < 6 && i*2+3 < len(reply); i++ {
lo := int(reply[i*2+2])
hi := int(reply[i*2+3])
val := lo | (hi << 8)
log.Printf(" Element %d: bytes[%d,%d] = [%d,%d] => %d mm", i, i*2+2, i*2+3, lo, hi, val)
}
// For now, use method 1 (original)
for i := 0; i < 6; i++ { for i := 0; i < 6; i++ {
if i*2+1 >= len(reply) {
break
}
lo := int(reply[i*2]) lo := int(reply[i*2])
hi := int(reply[i*2+1]) hi := int(reply[i*2+1])
lengths[i] = lo | (hi << 8) lengths[i] = lo | (hi << 8)
} }
log.Printf("Final lengths: %v", lengths)
return lengths, nil return lengths, nil
} }

View File

@@ -117,11 +117,8 @@
<div class="row"> <div class="row">
<AntennaGenius status={status?.antenna_genius} /> <AntennaGenius status={status?.antenna_genius} />
<RotatorGenius status={status?.rotator_genius} />
</div>
<div class="row">
<Ultrabeam status={status?.ultrabeam} /> <Ultrabeam status={status?.ultrabeam} />
<RotatorGenius status={status?.rotator_genius} />
</div> </div>
</div> </div>
</main> </main>
@@ -181,13 +178,41 @@
} }
.solar-item { .solar-item {
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.9);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.3px;
} }
.solar-item .value { .solar-item .value {
color: var(--accent-teal); font-weight: 700;
font-weight: 500;
margin-left: 4px; margin-left: 4px;
font-size: 14px;
}
.solar-item:nth-child(1) .value { /* SFI */
color: #ffa726;
text-shadow: 0 0 8px rgba(255, 167, 38, 0.5);
}
.solar-item:nth-child(2) .value { /* Spots */
color: #66bb6a;
text-shadow: 0 0 8px rgba(102, 187, 106, 0.5);
}
.solar-item:nth-child(3) .value { /* A */
color: #42a5f5;
text-shadow: 0 0 8px rgba(66, 165, 245, 0.5);
}
.solar-item:nth-child(4) .value { /* K */
color: #ef5350;
text-shadow: 0 0 8px rgba(239, 83, 80, 0.5);
}
.solar-item:nth-child(5) .value { /* G */
color: #ab47bc;
text-shadow: 0 0 8px rgba(171, 71, 188, 0.5);
} }
.header-right { .header-right {

View File

@@ -62,40 +62,26 @@
</div> </div>
<div class="metrics"> <div class="metrics">
<!-- Power Display - Big and Bold --> <!-- Power Display + SWR Side by Side -->
<div class="power-display"> <div class="power-swr-row">
<div class="power-main"> <div class="power-section">
<div class="power-value">{powerForward.toFixed(0)}<span class="unit">W</span></div> <div class="power-header">
<div class="power-label">Forward Power</div> <span class="power-label-inline">Power</span>
</div> <span class="power-value-inline">{powerForward.toFixed(0)} W</span>
<div class="power-bar">
<div class="power-bar-fill" style="width: {powerPercent}%">
<div class="power-bar-glow"></div>
</div> </div>
<div class="power-scale"> <div class="power-bar-container">
<span>0</span> <div class="power-bar-bg">
<span>1000</span> <div class="power-bar-fill" style="width: {powerPercent}%">
<span>2000</span> <div class="power-bar-glow"></div>
</div>
</div>
</div> </div>
</div> </div>
</div>
<!-- SWR Circle Indicator --> <!-- SWR Circle Compact -->
<div class="swr-container"> <div class="swr-circle-compact" style="--swr-color: {swrColor}">
<div class="swr-circle" style="--swr-color: {swrColor}"> <div class="swr-value-compact">{swr.toFixed(2)}</div>
<div class="swr-value">{swr.toFixed(2)}</div> <div class="swr-label-compact">SWR</div>
<div class="swr-label">SWR</div>
</div>
<div class="swr-status">
{#if swr < 1.5}
<span class="status-text good">Excellent</span>
{:else if swr < 2.0}
<span class="status-text ok">Good</span>
{:else if swr < 3.0}
<span class="status-text warning">Caution</span>
{:else}
<span class="status-text danger">High!</span>
{/if}
</div> </div>
</div> </div>
@@ -147,8 +133,8 @@
<!-- Fan Control --> <!-- Fan Control -->
<div class="fan-control"> <div class="fan-control">
<label class="control-label">Fan Mode</label> <label for="fan-mode-select" class="control-label">Fan Mode</label>
<select value={fanMode} on:change={(e) => setFanMode(e.target.value)}> <select id="fan-mode-select" value={fanMode} on:change={(e) => setFanMode(e.target.value)}>
<option value="STANDARD">Standard</option> <option value="STANDARD">Standard</option>
<option value="CONTEST">Contest</option> <option value="CONTEST">Contest</option>
<option value="BROADCAST">Broadcast</option> <option value="BROADCAST">Broadcast</option>
@@ -235,10 +221,108 @@
padding: 16px; padding: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 10px;
} }
/* Power Display */ /* Power Display */
/* Power + SWR Row */
.power-swr-row {
display: flex;
gap: 16px;
align-items: center;
}
.power-section {
flex: 1;
background: rgba(15, 23, 42, 0.6);
padding: 12px;
border-radius: 10px;
border: 1px solid rgba(79, 195, 247, 0.2);
}
.power-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.power-label-inline {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.power-value-inline {
font-size: 22px;
font-weight: 600;
color: #66bb6a;
}
.power-bar-container {
position: relative;
}
.power-bar-bg {
width: 100%;
height: 28px;
background: rgba(0, 0, 0, 0.3);
border-radius: 14px;
overflow: hidden;
position: relative;
}
.power-bar-fill {
position: relative;
height: 100%;
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
border-radius: 14px;
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%); }
}
.swr-circle-compact {
width: 90px;
height: 90px;
border-radius: 50%;
background: radial-gradient(circle, rgba(79, 195, 247, 0.1), transparent);
border: 4px solid var(--swr-color);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 0 25px var(--swr-color);
flex-shrink: 0;
}
.swr-value-compact {
font-size: 28px;
font-weight: 700;
color: var(--swr-color);
}
.swr-label-compact {
font-size: 11px;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
margin-top: 2px;
}
.power-display { .power-display {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -258,7 +342,7 @@
} }
.power-value .unit { .power-value .unit {
font-size: 24px; font-size: 20px;
color: var(--text-secondary); color: var(--text-secondary);
margin-left: 4px; margin-left: 4px;
} }
@@ -314,7 +398,7 @@
.swr-container { .swr-container {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 10px;
} }
.swr-circle { .swr-circle {
@@ -331,7 +415,7 @@
} }
.swr-value { .swr-value {
font-size: 24px; font-size: 20px;
font-weight: 300; font-weight: 300;
color: var(--swr-color); color: var(--swr-color);
} }
@@ -422,7 +506,7 @@
} }
.param-value { .param-value {
font-size: 18px; font-size: 16px;
font-weight: 300; font-weight: 300;
color: var(--text-primary); color: var(--text-primary);
margin-top: 2px; margin-top: 2px;

View File

@@ -53,6 +53,29 @@
console.error('Failed to stop:', err); console.error('Failed to stop:', err);
} }
} }
// Handle click on compass to set heading
function handleCompassClick(event) {
const svg = event.currentTarget;
const rect = svg.getBoundingClientRect();
const centerX = rect.width / 2;
const centerY = rect.height / 2;
// Get click position relative to center
const x = event.clientX - rect.left - centerX;
const y = event.clientY - rect.top - centerY;
// Calculate angle (0° = North/top, clockwise)
let angle = Math.atan2(x, -y) * (180 / Math.PI);
if (angle < 0) angle += 360;
// Round to nearest 5 degrees
const roundedHeading = Math.round(angle / 5) * 5;
// Set target and go
targetHeading = roundedHeading;
goToHeading();
}
</script> </script>
<div class="card"> <div class="card">
@@ -62,15 +85,29 @@
</div> </div>
<div class="metrics"> <div class="metrics">
<!-- Current Heading Display --> <!-- Current Heading Display with Compact Controls -->
<div class="heading-display"> <div class="heading-controls-row">
<div class="heading-label">CURRENT HEADING</div> <div class="heading-display-compact">
<div class="heading-value">{heading}°</div> <div class="heading-label">CURRENT HEADING</div>
<div class="heading-value">{heading}°</div>
</div>
<div class="controls-compact">
<button class="btn-mini ccw" on:click={rotateCCW} title="Rotate Counter-Clockwise">
</button>
<button class="btn-mini stop" on:click={stop} title="Stop Rotation">
</button>
<button class="btn-mini cw" on:click={rotateCW} title="Rotate Clockwise">
</button>
</div>
</div> </div>
<!-- Map with Beam --> <!-- Map with Beam -->
<div class="map-container"> <div class="map-container">
<svg viewBox="0 0 300 300" class="map-svg"> <svg viewBox="0 0 300 300" class="map-svg clickable-compass" on:click={handleCompassClick}>
<defs> <defs>
<!-- Gradient for beam --> <!-- Gradient for beam -->
<radialGradient id="beamGradient"> <radialGradient id="beamGradient">
@@ -139,32 +176,6 @@
</div> </div>
<!-- Go To Heading --> <!-- Go To Heading -->
<div class="goto-container">
<input
type="number"
min="0"
max="359"
bind:value={targetHeading}
placeholder="Enter heading"
class="heading-input"
/>
<button class="go-btn" on:click={goToHeading}>GO</button>
</div>
<!-- Control Buttons -->
<div class="controls">
<button class="control-btn ccw" on:click={rotateCCW}>
<span class="arrow"></span>
CCW
</button>
<button class="control-btn stop" on:click={stop}>
STOP
</button>
<button class="control-btn cw" on:click={rotateCW}>
<span class="arrow"></span>
CW
</button>
</div>
</div> </div>
</div> </div>
@@ -212,7 +223,7 @@
padding: 16px; padding: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 10px;
} }
/* Heading Display */ /* Heading Display */
@@ -224,6 +235,71 @@
border: 1px solid rgba(79, 195, 247, 0.3); border: 1px solid rgba(79, 195, 247, 0.3);
} }
.heading-controls-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 8px;
background: rgba(79, 195, 247, 0.1);
border-radius: 6px;
border: 1px solid rgba(79, 195, 247, 0.3);
}
.heading-display-compact {
flex: 1;
text-align: center;
}
.controls-compact {
display: flex;
gap: 6px;
}
.btn-mini {
width: 36px;
height: 36px;
border: none;
border-radius: 6px;
font-size: 20px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-mini.ccw {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-mini.ccw:hover {
transform: rotate(-15deg) scale(1.05);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-mini.stop {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.btn-mini.stop:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(245, 87, 108, 0.4);
}
.btn-mini.cw {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
.btn-mini.cw:hover {
transform: rotate(15deg) scale(1.05);
box-shadow: 0 4px 12px rgba(79, 172, 254, 0.4);
}
.heading-label { .heading-label {
font-size: 10px; font-size: 10px;
color: var(--text-muted); color: var(--text-muted);
@@ -254,9 +330,18 @@
height: auto; height: auto;
} }
.clickable-compass {
cursor: crosshair;
user-select: none;
}
.clickable-compass:hover {
filter: brightness(1.1);
}
.cardinal { .cardinal {
fill: var(--accent-cyan); fill: var(--accent-cyan);
font-size: 18px; font-size: 16px;
font-weight: 700; font-weight: 700;
text-shadow: 0 0 10px rgba(79, 195, 247, 0.8); text-shadow: 0 0 10px rgba(79, 195, 247, 0.8);
} }

View File

@@ -57,40 +57,26 @@
</div> </div>
<div class="metrics"> <div class="metrics">
<!-- Power Display --> <!-- Power Display + SWR Side by Side -->
<div class="power-display"> <div class="power-swr-row">
<div class="power-main"> <div class="power-section">
<div class="power-value">{powerForward.toFixed(0)}<span class="unit">W</span></div> <div class="power-header">
<div class="power-label">Forward Power</div> <span class="power-label-inline">Power</span>
</div> <span class="power-value-inline">{powerForward.toFixed(0)} W</span>
<div class="power-bar">
<div class="power-bar-fill" style="width: {powerPercent}%">
<div class="power-bar-glow"></div>
</div> </div>
<div class="power-scale"> <div class="power-bar-container">
<span>0</span> <div class="power-bar-bg">
<span>1000</span> <div class="power-bar-fill" style="width: {powerPercent}%">
<span>2000</span> <div class="power-bar-glow"></div>
</div>
</div>
</div> </div>
</div> </div>
</div>
<!-- SWR Circle --> <!-- SWR Circle Compact -->
<div class="swr-container"> <div class="swr-circle-compact" style="--swr-color: {swrColor}">
<div class="swr-circle" style="--swr-color: {swrColor}"> <div class="swr-value-compact">{swr.toFixed(2)}</div>
<div class="swr-value">{swr.toFixed(2)}</div> <div class="swr-label-compact">SWR</div>
<div class="swr-label">SWR</div>
</div>
<div class="swr-status">
{#if swr < 1.5}
<span class="status-text good">Excellent</span>
{:else if swr < 2.0}
<span class="status-text ok">Good</span>
{:else if swr < 3.0}
<span class="status-text warning">Caution</span>
{:else}
<span class="status-text danger">High!</span>
{/if}
</div> </div>
</div> </div>
@@ -219,55 +205,63 @@
padding: 16px; padding: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 10px;
} }
/* Power Display */ /* Power Display */
.power-display { /* Power + SWR Row */
.power-swr-row {
display: flex; display: flex;
flex-direction: column; gap: 16px;
gap: 8px; align-items: center;
} }
.power-main { .power-section {
text-align: center; flex: 1;
background: rgba(15, 23, 42, 0.6);
padding: 12px;
border-radius: 10px;
border: 1px solid rgba(79, 195, 247, 0.2);
} }
.power-value { .power-header {
font-size: 48px; display: flex;
font-weight: 200; justify-content: space-between;
color: var(--accent-cyan); align-items: center;
line-height: 1; margin-bottom: 10px;
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
} }
.power-value .unit { .power-label-inline {
font-size: 24px; font-size: 12px;
color: var(--text-secondary); color: rgba(255, 255, 255, 0.7);
margin-left: 4px;
}
.power-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 0.5px;
margin-top: 4px;
} }
.power-bar { .power-value-inline {
font-size: 22px;
font-weight: 600;
color: #66bb6a;
}
.power-bar-container {
position: relative; position: relative;
height: 8px; }
background: var(--bg-tertiary);
border-radius: 4px; .power-bar-bg {
width: 100%;
height: 28px;
background: rgba(0, 0, 0, 0.3);
border-radius: 14px;
overflow: hidden; overflow: hidden;
position: relative;
} }
.power-bar-fill { .power-bar-fill {
position: relative; position: relative;
height: 100%; height: 100%;
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336); background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
border-radius: 4px; border-radius: 14px;
transition: width 0.3s ease; transition: width 0.3s ease;
} }
@@ -286,19 +280,44 @@
100% { transform: translateX(100%); } 100% { transform: translateX(100%); }
} }
.power-scale { .swr-circle-compact {
width: 90px;
height: 90px;
border-radius: 50%;
background: radial-gradient(circle, rgba(79, 195, 247, 0.1), transparent);
border: 4px solid var(--swr-color);
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
font-size: 9px; align-items: center;
color: var(--text-muted); justify-content: center;
margin-top: 4px; box-shadow: 0 0 25px var(--swr-color);
flex-shrink: 0;
}
.swr-value-compact {
font-size: 28px;
font-weight: 700;
color: var(--swr-color);
}
.swr-label-compact {
font-size: 11px;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
margin-top: 2px;
}
.power-display {
display: flex;
flex-direction: column;
gap: 8px;
} }
/* SWR Circle */ /* SWR Circle */
.swr-container { .swr-container {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 10px;
} }
.swr-circle { .swr-circle {
@@ -315,7 +334,7 @@
} }
.swr-value { .swr-value {
font-size: 24px; font-size: 20px;
font-weight: 300; font-weight: 300;
color: var(--swr-color); color: var(--swr-color);
} }
@@ -361,7 +380,7 @@
} }
.cap-value { .cap-value {
font-size: 32px; font-size: 20px;
font-weight: 300; font-weight: 300;
color: var(--accent-cyan); color: var(--accent-cyan);
text-shadow: 0 0 15px rgba(79, 195, 247, 0.5); text-shadow: 0 0 15px rgba(79, 195, 247, 0.5);
@@ -472,6 +491,6 @@
} }
.tune-icon { .tune-icon {
font-size: 18px; font-size: 16px;
} }
</style> </style>

View File

@@ -13,34 +13,96 @@
$: elementLengths = status?.element_lengths || []; $: elementLengths = status?.element_lengths || [];
$: firmwareVersion = status ? `${status.firmware_major}.${status.firmware_minor}` : '0.0'; $: firmwareVersion = status ? `${status.firmware_major}.${status.firmware_minor}` : '0.0';
// Band names mapping // Band names mapping - VL2.3 covers 6M to 40M only
// Band 0=6M, 1=10M, 2=12M, 3=15M, 4=17M, 5=20M, 6=30M, 7=40M
const bandNames = [ const bandNames = [
'160M', '80M', '60M', '40M', '30M', '20M', '6M', '10M', '12M', '15M', '17M', '20M', '30M', '40M'
'17M', '15M', '12M', '10M', '6M'
]; ];
// Detect band from frequency
$: detectedBand = detectBandFromFrequency(frequency, band);
function detectBandFromFrequency(freq, bandIndex) {
// If band index is valid (0-7), use it directly
if (bandIndex >= 0 && bandIndex <= 7) {
return bandNames[bandIndex];
}
// Otherwise detect from frequency (in kHz)
if (freq >= 7000 && freq <= 7300) return '40M';
if (freq >= 10100 && freq <= 10150) return '30M';
if (freq >= 14000 && freq <= 14350) return '20M';
if (freq >= 18068 && freq <= 18168) return '17M';
if (freq >= 21000 && freq <= 21450) return '15M';
if (freq >= 24890 && freq <= 24990) return '12M';
if (freq >= 28000 && freq <= 29700) return '10M';
if (freq >= 50000 && freq <= 54000) return '6M';
return 'Unknown';
}
// Direction names // Direction names
const directionNames = ['Normal', '180°', 'Bi-Dir']; const directionNames = ['Normal', '180°', 'Bi-Dir'];
// Auto-track threshold options
const thresholdOptions = [
{ value: 25, label: '25 kHz' },
{ value: 50, label: '50 kHz' },
{ value: 100, label: '100 kHz' }
];
// Auto-track state
let autoTrackEnabled = true; // Default enabled
let autoTrackThreshold = 25; // Default 25 kHz
// Form state // Form state
let targetFreq = 0;
let targetDirection = 0; let targetDirection = 0;
// Auto-update targetDirection when status changes
$: targetDirection = direction;
// Element names based on band (corrected order: 0=6M ... 10=160M)
$: elementNames = getElementNames(band);
function getElementNames(band) {
// 30M (band 6) and 40M (band 7): Reflector (inverted), Radiator (inverted)
if (band === 6 || band === 7) {
return ['Radiator (30/40M)', 'Reflector (30/40M)', null];
}
// 6M to 20M (bands 0-5): Reflector, Radiator, Director 1
if (band >= 0 && band <= 5) {
return ['Reflector', 'Radiator', 'Director 1'];
}
// Default
return ['Element 1', 'Element 2', 'Element 3'];
}
// Element calibration state // Element calibration state
let calibrationMode = false; let calibrationMode = false;
let selectedElement = 0; let selectedElement = 0;
let elementAdjustment = 0; let elementAdjustment = 0;
async function setFrequency() { async function setDirection() {
if (targetFreq < 1800 || targetFreq > 30000) { if (frequency === 0) {
alert('Frequency must be between 1.8 MHz and 30 MHz'); return; // Silently skip if no frequency
return;
} }
try { try {
await api.ultrabeam.setFrequency(targetFreq, targetDirection); // Send command to antenna with current frequency and new direction
await api.ultrabeam.setFrequency(frequency, targetDirection);
// Also save direction preference for auto-track
await api.ultrabeam.setDirection(targetDirection);
} catch (err) { } catch (err) {
console.error('Failed to set frequency:', err); // Log error but don't alert - code 30 (busy) is normal
alert('Failed to set frequency'); console.log('Direction change sent (may show code 30 if busy):', err);
}
}
async function updateAutoTrack() {
try {
await api.ultrabeam.setAutoTrack(autoTrackEnabled, autoTrackThreshold);
} catch (err) {
console.error('Failed to update auto-track:', err);
alert('Failed to update auto-track settings');
} }
} }
@@ -88,50 +150,56 @@
<div class="status-item"> <div class="status-item">
<div class="status-label">Band</div> <div class="status-label">Band</div>
<div class="status-value band">{bandNames[band] || 'Unknown'}</div> <div class="status-value band">{detectedBand}</div>
</div> </div>
<div class="status-item"> <div class="status-item">
<div class="status-label">Direction</div> <div class="status-label">Direction</div>
<div class="status-value direction">{directionNames[direction]}</div> <div class="status-value direction">{directionNames[direction]}</div>
</div> </div>
<div class="status-item">
<div class="status-label">Firmware</div>
<div class="status-value fw">v{firmwareVersion}</div>
</div>
</div> </div>
<!-- Frequency Control --> <!-- Auto-Track Control -->
<div class="control-section"> <div class="control-section compact">
<h3>Frequency Control</h3> <h3>Auto Tracking</h3>
<div class="freq-control"> <div class="auto-track-controls">
<div class="input-group"> <label class="toggle-label">
<label for="target-freq">Target Frequency (KHz)</label> <input type="checkbox" bind:checked={autoTrackEnabled} on:change={updateAutoTrack} />
<input <span>Enable Auto-Track from Tuner</span>
id="target-freq" </label>
type="number"
bind:value={targetFreq}
min="1800"
max="30000"
step="1"
placeholder="e.g. 14200"
/>
</div>
<div class="input-group"> <div class="threshold-group">
<label for="target-dir">Direction</label> <label for="threshold-select">Threshold:</label>
<select id="target-dir" bind:value={targetDirection}> <select id="threshold-select" bind:value={autoTrackThreshold} on:change={updateAutoTrack}>
<option value={0}>Normal</option> {#each thresholdOptions as option}
<option value={1}>180°</option> <option value={option.value}>{option.label}</option>
<option value={2}>Bi-Directional</option> {/each}
</select> </select>
</div> </div>
<button class="btn-primary" on:click={setFrequency}> <div class="direction-buttons">
<span class="icon">📡</span> <button
Set Frequency class="dir-btn normal"
</button> class:active={targetDirection === 0}
on:click={() => { targetDirection = 0; setDirection(); }}
>
Normal
</button>
<button
class="dir-btn rotate180"
class:active={targetDirection === 1}
on:click={() => { targetDirection = 1; setDirection(); }}
>
180°
</button>
<button
class="dir-btn bidir"
class:active={targetDirection === 2}
on:click={() => { targetDirection = 2; setDirection(); }}
>
Bi-Dir
</button>
</div>
</div> </div>
</div> </div>
@@ -146,22 +214,25 @@
</div> </div>
{/if} {/if}
<!-- Element Lengths Display --> <!-- Element Lengths Display - HIDDEN: Command 9 returns status instead -->
<!--
<div class="elements-section"> <div class="elements-section">
<h3>Element Lengths (mm)</h3> <h3>Element Lengths (mm)</h3>
<div class="elements-grid"> <div class="elements-grid">
{#each elementLengths as length, i} {#each elementLengths.slice(0, 3) as length, i}
{#if length > 0} {#if length > 0 && elementNames[i]}
<div class="element-item"> <div class="element-item">
<div class="element-label">Element {i + 1}</div> <div class="element-label">{elementNames[i]}</div>
<div class="element-value">{length} mm</div> <div class="element-value">{length} mm</div>
</div> </div>
{/if} {/if}
{/each} {/each}
</div> </div>
</div> </div>
-->
<!-- Calibration Mode --> <!-- Calibration Mode - HIDDEN: Command 9 doesn't work -->
<!--
<div class="calibration-section"> <div class="calibration-section">
<div class="section-header"> <div class="section-header">
<h3>Calibration</h3> <h3>Calibration</h3>
@@ -179,9 +250,9 @@
<div class="input-group"> <div class="input-group">
<label for="element-select">Element</label> <label for="element-select">Element</label>
<select id="element-select" bind:value={selectedElement}> <select id="element-select" bind:value={selectedElement}>
{#each elementLengths as length, i} {#each elementLengths.slice(0, 3) as length, i}
{#if length > 0} {#if length > 0 && elementNames[i]}
<option value={i}>Element {i + 1} ({length}mm)</option> <option value={i}>{elementNames[i]} ({length}mm)</option>
{/if} {/if}
{/each} {/each}
</select> </select>
@@ -209,6 +280,7 @@
</div> </div>
{/if} {/if}
</div> </div>
-->
<!-- Actions --> <!-- Actions -->
<div class="actions"> <div class="actions">
@@ -224,7 +296,7 @@
.card { .card {
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%); background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%);
border-radius: 16px; border-radius: 16px;
padding: 24px; padding: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
border: 1px solid rgba(79, 195, 247, 0.2); border: 1px solid rgba(79, 195, 247, 0.2);
} }
@@ -240,7 +312,7 @@
h2 { h2 {
margin: 0; margin: 0;
font-size: 24px; font-size: 20px;
font-weight: 600; font-weight: 600;
background: linear-gradient(135deg, #4fc3f7 0%, #03a9f4 100%); background: linear-gradient(135deg, #4fc3f7 0%, #03a9f4 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
@@ -278,14 +350,14 @@
.metrics { .metrics {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 12px;
} }
/* Status Grid */ /* Status Grid */
.status-grid { .status-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px; gap: 10px;
} }
.status-item { .status-item {
@@ -304,32 +376,140 @@
} }
.status-value { .status-value {
font-size: 20px; font-size: 22px;
font-weight: 600; font-weight: 700;
color: #4fc3f7; color: #4fc3f7;
text-shadow: 0 0 10px rgba(79, 195, 247, 0.5);
} }
.status-value.freq { .status-value.freq {
color: #66bb6a; color: #66bb6a;
font-size: 24px; font-size: 22px;
text-shadow: 0 0 10px rgba(102, 187, 106, 0.5);
} }
.status-value.band { .status-value.band {
color: #ffa726; color: #ffa726;
text-shadow: 0 0 10px rgba(255, 167, 38, 0.5);
} }
.status-value.direction { .status-value.direction {
color: #ab47bc; color: #ab47bc;
text-shadow: 0 0 10px rgba(171, 71, 188, 0.5);
} }
/* Control Section */ /* Control Section */
.control-section { .control-section {
background: rgba(15, 23, 42, 0.4); background: rgba(15, 23, 42, 0.4);
padding: 20px; padding: 12px;
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(79, 195, 247, 0.2); border: 1px solid rgba(79, 195, 247, 0.2);
} }
.control-section.compact {
padding: 16px;
}
.auto-track-controls {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.toggle-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: #fff;
font-size: 14px;
}
.toggle-label input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.threshold-group {
display: flex;
align-items: center;
gap: 8px;
}
.threshold-group label {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
white-space: nowrap;
}
.direction-buttons {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 12px;
}
.dir-btn {
padding: 14px 20px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
cursor: pointer;
transition: all 0.3s;
color: white;
letter-spacing: 0.5px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.dir-btn.normal {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.dir-btn.normal:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
}
.dir-btn.normal.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-shadow: 0 0 25px rgba(102, 126, 234, 0.8), 0 6px 20px rgba(102, 126, 234, 0.5);
transform: translateY(-2px);
}
.dir-btn.rotate180 {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.dir-btn.rotate180:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(245, 87, 108, 0.5);
}
.dir-btn.rotate180.active {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
box-shadow: 0 0 25px rgba(245, 87, 108, 0.8), 0 6px 20px rgba(245, 87, 108, 0.5);
transform: translateY(-2px);
}
.dir-btn.bidir {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.dir-btn.bidir:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(79, 172, 254, 0.5);
}
.dir-btn.bidir.active {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
box-shadow: 0 0 25px rgba(79, 172, 254, 0.8), 0 6px 20px rgba(79, 172, 254, 0.5);
transform: translateY(-2px);
}
.freq-control { .freq-control {
display: grid; display: grid;
grid-template-columns: 2fr 1fr auto; grid-template-columns: 2fr 1fr auto;
@@ -437,7 +617,7 @@
/* Progress */ /* Progress */
.progress-section { .progress-section {
background: rgba(15, 23, 42, 0.4); background: rgba(15, 23, 42, 0.4);
padding: 20px; padding: 12px;
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(255, 193, 7, 0.3); border: 1px solid rgba(255, 193, 7, 0.3);
} }
@@ -468,7 +648,7 @@
/* Elements */ /* Elements */
.elements-section { .elements-section {
background: rgba(15, 23, 42, 0.4); background: rgba(15, 23, 42, 0.4);
padding: 20px; padding: 12px;
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(79, 195, 247, 0.2); border: 1px solid rgba(79, 195, 247, 0.2);
} }
@@ -502,7 +682,7 @@
/* Calibration */ /* Calibration */
.calibration-section { .calibration-section {
background: rgba(255, 152, 0, 0.05); background: rgba(255, 152, 0, 0.05);
padding: 20px; padding: 12px;
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(255, 152, 0, 0.3); border: 1px solid rgba(255, 152, 0, 0.3);
} }
@@ -517,7 +697,7 @@
.calibration-controls { .calibration-controls {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 10px;
} }
.warning-text { .warning-text {

View File

@@ -97,5 +97,9 @@ export const api = {
body: JSON.stringify({ frequency, direction }), body: JSON.stringify({ frequency, direction }),
}), }),
retract: () => request('/ultrabeam/retract', { method: 'POST' }), retract: () => request('/ultrabeam/retract', { method: 'POST' }),
setAutoTrack: (enabled, threshold) => request('/ultrabeam/autotrack', {
method: 'POST',
body: JSON.stringify({ enabled, threshold }),
}),
}, },
}; };