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.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-8_72Rq0c.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ml--d1Bc.css">
<script type="module" crossorigin src="/assets/index-DIrlWzGj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DvnnYzjx.css">
</head>
<body>
<div id="app"></div>

View File

@@ -34,6 +34,13 @@ type DeviceManager struct {
updateInterval time.Duration
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 {
@@ -54,6 +61,10 @@ func NewDeviceManager(cfg *config.Config, hub *Hub) *DeviceManager {
hub: hub,
updateInterval: 1 * time.Second, // Update status every second
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
if ubStatus, err := dm.ultrabeam.GetStatus(); err == nil {
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 {
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)
if solarData, err := dm.solarClient.GetSolarData(); err == nil {
status.Solar = solarData
@@ -298,3 +367,13 @@ func (dm *DeviceManager) RotatorGenius() *rotatorgenius.Client {
func (dm *DeviceManager) Ultrabeam() *ultrabeam.Client {
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
mux.HandleFunc("/api/ultrabeam/frequency", s.handleUltrabeamFrequency)
mux.HandleFunc("/api/ultrabeam/retract", s.handleUltrabeamRetract)
mux.HandleFunc("/api/ultrabeam/autotrack", s.handleUltrabeamAutoTrack)
// Tuner endpoints
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"})
}
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{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)

View File

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

View File

@@ -103,17 +103,20 @@ func (c *Client) Stop() {
}
func (c *Client) pollLoop() {
ticker := time.NewTicker(500 * time.Millisecond)
ticker := time.NewTicker(2 * time.Second) // Increased from 500ms to 2s
defer ticker.Stop()
for {
select {
case <-ticker.C:
// Try to connect if not connected
c.connMu.Lock()
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)
if err != nil {
log.Printf("Ultrabeam: Connection failed: %v", err)
c.connMu.Unlock()
// Mark as disconnected
@@ -151,12 +154,6 @@ func (c *Client) pollLoop() {
// Mark as connected
status.Connected = true
// Query element lengths
lengths, err := c.queryElementLengths()
if err == nil {
status.ElementLengths = lengths
}
// Query progress if motors moving
if status.MotorsMoving != 0 {
progress, err := c.queryProgress()
@@ -312,7 +309,7 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
}
// 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
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)
}
// 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
switch replyCmd {
case UB_BAD:
@@ -352,7 +354,10 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
case UB_OK:
return payload, nil
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
}
// 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 {
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)
// 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++ {
if i*2+1 >= len(reply) {
break
}
lo := int(reply[i*2])
hi := int(reply[i*2+1])
lengths[i] = lo | (hi << 8)
}
log.Printf("Final lengths: %v", lengths)
return lengths, nil
}

View File

@@ -117,11 +117,8 @@
<div class="row">
<AntennaGenius status={status?.antenna_genius} />
<RotatorGenius status={status?.rotator_genius} />
</div>
<div class="row">
<Ultrabeam status={status?.ultrabeam} />
<RotatorGenius status={status?.rotator_genius} />
</div>
</div>
</main>
@@ -181,13 +178,41 @@
}
.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 {
color: var(--accent-teal);
font-weight: 500;
font-weight: 700;
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 {

View File

@@ -62,40 +62,26 @@
</div>
<div class="metrics">
<!-- Power Display - Big and Bold -->
<div class="power-display">
<div class="power-main">
<div class="power-value">{powerForward.toFixed(0)}<span class="unit">W</span></div>
<div class="power-label">Forward Power</div>
<!-- Power Display + SWR Side by Side -->
<div class="power-swr-row">
<div class="power-section">
<div class="power-header">
<span class="power-label-inline">Power</span>
<span class="power-value-inline">{powerForward.toFixed(0)} W</span>
</div>
<div class="power-bar">
<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 class="power-scale">
<span>0</span>
<span>1000</span>
<span>2000</span>
</div>
</div>
</div>
<!-- SWR Circle Indicator -->
<div class="swr-container">
<div class="swr-circle" style="--swr-color: {swrColor}">
<div class="swr-value">{swr.toFixed(2)}</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}
<!-- SWR Circle Compact -->
<div class="swr-circle-compact" style="--swr-color: {swrColor}">
<div class="swr-value-compact">{swr.toFixed(2)}</div>
<div class="swr-label-compact">SWR</div>
</div>
</div>
@@ -147,8 +133,8 @@
<!-- Fan Control -->
<div class="fan-control">
<label class="control-label">Fan Mode</label>
<select value={fanMode} on:change={(e) => setFanMode(e.target.value)}>
<label for="fan-mode-select" class="control-label">Fan Mode</label>
<select id="fan-mode-select" value={fanMode} on:change={(e) => setFanMode(e.target.value)}>
<option value="STANDARD">Standard</option>
<option value="CONTEST">Contest</option>
<option value="BROADCAST">Broadcast</option>
@@ -235,10 +221,108 @@
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
gap: 10px;
}
/* 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 {
display: flex;
flex-direction: column;
@@ -258,7 +342,7 @@
}
.power-value .unit {
font-size: 24px;
font-size: 20px;
color: var(--text-secondary);
margin-left: 4px;
}
@@ -314,7 +398,7 @@
.swr-container {
display: flex;
align-items: center;
gap: 16px;
gap: 10px;
}
.swr-circle {
@@ -331,7 +415,7 @@
}
.swr-value {
font-size: 24px;
font-size: 20px;
font-weight: 300;
color: var(--swr-color);
}
@@ -422,7 +506,7 @@
}
.param-value {
font-size: 18px;
font-size: 16px;
font-weight: 300;
color: var(--text-primary);
margin-top: 2px;

View File

@@ -53,6 +53,29 @@
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>
<div class="card">
@@ -62,15 +85,29 @@
</div>
<div class="metrics">
<!-- Current Heading Display -->
<div class="heading-display">
<!-- Current Heading Display with Compact Controls -->
<div class="heading-controls-row">
<div class="heading-display-compact">
<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>
<!-- Map with Beam -->
<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>
<!-- Gradient for beam -->
<radialGradient id="beamGradient">
@@ -139,32 +176,6 @@
</div>
<!-- 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>
@@ -212,7 +223,7 @@
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
gap: 10px;
}
/* Heading Display */
@@ -224,6 +235,71 @@
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 {
font-size: 10px;
color: var(--text-muted);
@@ -254,9 +330,18 @@
height: auto;
}
.clickable-compass {
cursor: crosshair;
user-select: none;
}
.clickable-compass:hover {
filter: brightness(1.1);
}
.cardinal {
fill: var(--accent-cyan);
font-size: 18px;
font-size: 16px;
font-weight: 700;
text-shadow: 0 0 10px rgba(79, 195, 247, 0.8);
}

View File

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

View File

@@ -13,34 +13,96 @@
$: elementLengths = status?.element_lengths || [];
$: 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 = [
'160M', '80M', '60M', '40M', '30M', '20M',
'17M', '15M', '12M', '10M', '6M'
'6M', '10M', '12M', '15M', '17M', '20M', '30M', '40M'
];
// 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
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
let targetFreq = 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
let calibrationMode = false;
let selectedElement = 0;
let elementAdjustment = 0;
async function setFrequency() {
if (targetFreq < 1800 || targetFreq > 30000) {
alert('Frequency must be between 1.8 MHz and 30 MHz');
return;
async function setDirection() {
if (frequency === 0) {
return; // Silently skip if no frequency
}
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) {
console.error('Failed to set frequency:', err);
alert('Failed to set frequency');
// Log error but don't alert - code 30 (busy) is normal
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-label">Band</div>
<div class="status-value band">{bandNames[band] || 'Unknown'}</div>
<div class="status-value band">{detectedBand}</div>
</div>
<div class="status-item">
<div class="status-label">Direction</div>
<div class="status-value direction">{directionNames[direction]}</div>
</div>
<div class="status-item">
<div class="status-label">Firmware</div>
<div class="status-value fw">v{firmwareVersion}</div>
</div>
</div>
<!-- Frequency Control -->
<div class="control-section">
<h3>Frequency Control</h3>
<div class="freq-control">
<div class="input-group">
<label for="target-freq">Target Frequency (KHz)</label>
<input
id="target-freq"
type="number"
bind:value={targetFreq}
min="1800"
max="30000"
step="1"
placeholder="e.g. 14200"
/>
</div>
<!-- Auto-Track Control -->
<div class="control-section compact">
<h3>Auto Tracking</h3>
<div class="auto-track-controls">
<label class="toggle-label">
<input type="checkbox" bind:checked={autoTrackEnabled} on:change={updateAutoTrack} />
<span>Enable Auto-Track from Tuner</span>
</label>
<div class="input-group">
<label for="target-dir">Direction</label>
<select id="target-dir" bind:value={targetDirection}>
<option value={0}>Normal</option>
<option value={1}>180°</option>
<option value={2}>Bi-Directional</option>
<div class="threshold-group">
<label for="threshold-select">Threshold:</label>
<select id="threshold-select" bind:value={autoTrackThreshold} on:change={updateAutoTrack}>
{#each thresholdOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<button class="btn-primary" on:click={setFrequency}>
<span class="icon">📡</span>
Set Frequency
<div class="direction-buttons">
<button
class="dir-btn normal"
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>
@@ -146,22 +214,25 @@
</div>
{/if}
<!-- Element Lengths Display -->
<!-- Element Lengths Display - HIDDEN: Command 9 returns status instead -->
<!--
<div class="elements-section">
<h3>Element Lengths (mm)</h3>
<div class="elements-grid">
{#each elementLengths as length, i}
{#if length > 0}
{#each elementLengths.slice(0, 3) as length, i}
{#if length > 0 && elementNames[i]}
<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>
{/if}
{/each}
</div>
</div>
-->
<!-- Calibration Mode -->
<!-- Calibration Mode - HIDDEN: Command 9 doesn't work -->
<!--
<div class="calibration-section">
<div class="section-header">
<h3>Calibration</h3>
@@ -179,9 +250,9 @@
<div class="input-group">
<label for="element-select">Element</label>
<select id="element-select" bind:value={selectedElement}>
{#each elementLengths as length, i}
{#if length > 0}
<option value={i}>Element {i + 1} ({length}mm)</option>
{#each elementLengths.slice(0, 3) as length, i}
{#if length > 0 && elementNames[i]}
<option value={i}>{elementNames[i]} ({length}mm)</option>
{/if}
{/each}
</select>
@@ -209,6 +280,7 @@
</div>
{/if}
</div>
-->
<!-- Actions -->
<div class="actions">
@@ -224,7 +296,7 @@
.card {
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%);
border-radius: 16px;
padding: 24px;
padding: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
border: 1px solid rgba(79, 195, 247, 0.2);
}
@@ -240,7 +312,7 @@
h2 {
margin: 0;
font-size: 24px;
font-size: 20px;
font-weight: 600;
background: linear-gradient(135deg, #4fc3f7 0%, #03a9f4 100%);
-webkit-background-clip: text;
@@ -278,14 +350,14 @@
.metrics {
display: flex;
flex-direction: column;
gap: 24px;
gap: 12px;
}
/* Status Grid */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
gap: 10px;
}
.status-item {
@@ -304,32 +376,140 @@
}
.status-value {
font-size: 20px;
font-weight: 600;
font-size: 22px;
font-weight: 700;
color: #4fc3f7;
text-shadow: 0 0 10px rgba(79, 195, 247, 0.5);
}
.status-value.freq {
color: #66bb6a;
font-size: 24px;
font-size: 22px;
text-shadow: 0 0 10px rgba(102, 187, 106, 0.5);
}
.status-value.band {
color: #ffa726;
text-shadow: 0 0 10px rgba(255, 167, 38, 0.5);
}
.status-value.direction {
color: #ab47bc;
text-shadow: 0 0 10px rgba(171, 71, 188, 0.5);
}
/* Control Section */
.control-section {
background: rgba(15, 23, 42, 0.4);
padding: 20px;
padding: 12px;
border-radius: 12px;
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 {
display: grid;
grid-template-columns: 2fr 1fr auto;
@@ -437,7 +617,7 @@
/* Progress */
.progress-section {
background: rgba(15, 23, 42, 0.4);
padding: 20px;
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(255, 193, 7, 0.3);
}
@@ -468,7 +648,7 @@
/* Elements */
.elements-section {
background: rgba(15, 23, 42, 0.4);
padding: 20px;
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(79, 195, 247, 0.2);
}
@@ -502,7 +682,7 @@
/* Calibration */
.calibration-section {
background: rgba(255, 152, 0, 0.05);
padding: 20px;
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(255, 152, 0, 0.3);
}
@@ -517,7 +697,7 @@
.calibration-controls {
display: flex;
flex-direction: column;
gap: 16px;
gap: 10px;
}
.warning-text {

View File

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