582 lines
15 KiB
Svelte
582 lines
15 KiB
Svelte
<script>
|
|
import { api } from '../lib/api.js';
|
|
|
|
export let status;
|
|
export let flexradio = null;
|
|
|
|
$: connected = status?.connected || false;
|
|
$: frequency = status?.frequency || 0;
|
|
$: band = status?.band || 0;
|
|
$: direction = status?.direction || 0;
|
|
$: motorsMoving = status?.motors_moving || 0;
|
|
$: progressTotal = status?.progress_total || 0;
|
|
$: progressCurrent = status?.progress_current || 0;
|
|
$: elementLengths = status?.element_lengths || [];
|
|
$: firmwareVersion = status ? `${status.firmware_major}.${status.firmware_minor}` : '0.0';
|
|
|
|
// FlexRadio interlock
|
|
$: interlockConnected = flexradio?.connected || false;
|
|
$: 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';
|
|
case 'NOT_READY': return '#f44336';
|
|
case 'PTT_REQUESTED': return '#ffc107';
|
|
case 'TRANSMITTING': return '#ff9800';
|
|
default: return 'rgba(255, 255, 255, 0.3)';
|
|
}
|
|
}
|
|
|
|
// 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 = [
|
|
'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 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 setDirection() {
|
|
if (frequency === 0) {
|
|
return; // Silently skip if no frequency
|
|
}
|
|
try {
|
|
// 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) {
|
|
// 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);
|
|
// Removed alert popup - check console for errors
|
|
}
|
|
}
|
|
|
|
async function retract() {
|
|
if (!confirm('Retract all antenna elements?')) {
|
|
return;
|
|
}
|
|
try {
|
|
await api.ultrabeam.retract();
|
|
} catch (err) {
|
|
console.error('Failed to retract:', err);
|
|
// Removed alert popup - check console for errors
|
|
}
|
|
}
|
|
|
|
async function adjustElement() {
|
|
try {
|
|
const newLength = elementLengths[selectedElement] + elementAdjustment;
|
|
// TODO: Add API call when backend supports it
|
|
// Removed alert popup - check console for errors
|
|
elementAdjustment = 0;
|
|
} catch (err) {
|
|
console.error('Failed to adjust element:', err);
|
|
// Removed alert popup - check console for errors
|
|
}
|
|
}
|
|
|
|
// Calculate progress percentage
|
|
$: progressPercent = progressTotal > 0 ? (progressCurrent / 60) * 100 : 0;
|
|
</script>
|
|
|
|
<div class="card">
|
|
<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>
|
|
|
|
<div class="metrics">
|
|
<!-- Current Status -->
|
|
<div class="status-grid">
|
|
<div class="status-item">
|
|
<div class="status-label">Frequency</div>
|
|
<div class="status-value freq">{(frequency / 1000).toFixed(3)} MHz</div>
|
|
</div>
|
|
|
|
<div class="status-item">
|
|
<div class="status-label">Band</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>
|
|
|
|
<!-- 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 Radio</span>
|
|
</label>
|
|
|
|
<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>
|
|
</div>
|
|
|
|
<!-- Direction buttons on separate line -->
|
|
<div class="direction-buttons">
|
|
<button
|
|
class="dir-btn"
|
|
class:active={targetDirection === 0}
|
|
on:click={() => { targetDirection = 0; setDirection(); }}
|
|
>
|
|
Normal
|
|
</button>
|
|
<button
|
|
class="dir-btn"
|
|
class:active={targetDirection === 1}
|
|
on:click={() => { targetDirection = 1; setDirection(); }}
|
|
>
|
|
180°
|
|
</button>
|
|
<button
|
|
class="dir-btn"
|
|
class:active={targetDirection === 2}
|
|
on:click={() => { targetDirection = 2; setDirection(); }}
|
|
>
|
|
Bi-Dir
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Motor Progress -->
|
|
{#if motorsMoving > 0}
|
|
<div class="progress-section">
|
|
<h3>Motors Moving...</h3>
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" style="width: {progressPercent}%"></div>
|
|
</div>
|
|
<div class="progress-text">{progressCurrent} / 60 ({progressPercent.toFixed(0)}%)</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Element Lengths Display - HIDDEN: Command 9 returns status instead -->
|
|
<!--
|
|
<div class="elements-section">
|
|
<h3>Element Lengths (mm)</h3>
|
|
<div class="elements-grid">
|
|
{#each elementLengths.slice(0, 3) as length, i}
|
|
{#if length > 0 && elementNames[i]}
|
|
<div class="element-item">
|
|
<div class="element-label">{elementNames[i]}</div>
|
|
<div class="element-value">{length} mm</div>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
-->
|
|
|
|
<!-- Calibration Mode - HIDDEN: Command 9 doesn't work -->
|
|
<!--
|
|
<div class="calibration-section">
|
|
<div class="section-header">
|
|
<h3>Calibration</h3>
|
|
<button
|
|
class="btn-toggle"
|
|
class:active={calibrationMode}
|
|
on:click={() => calibrationMode = !calibrationMode}
|
|
>
|
|
{calibrationMode ? 'Hide' : 'Show'}
|
|
</button>
|
|
</div>
|
|
|
|
{#if calibrationMode}
|
|
<div class="calibration-controls">
|
|
<div class="input-group">
|
|
<label for="element-select">Element</label>
|
|
<select id="element-select" bind:value={selectedElement}>
|
|
{#each elementLengths.slice(0, 3) as length, i}
|
|
{#if length > 0 && elementNames[i]}
|
|
<option value={i}>{elementNames[i]} ({length}mm)</option>
|
|
{/if}
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="input-group">
|
|
<label for="adjustment">Adjustment (mm)</label>
|
|
<input
|
|
id="adjustment"
|
|
type="number"
|
|
bind:value={elementAdjustment}
|
|
step="1"
|
|
placeholder="±10"
|
|
/>
|
|
</div>
|
|
|
|
<button class="btn-caution" on:click={adjustElement}>
|
|
<span class="icon">⚙️</span>
|
|
Apply Adjustment
|
|
</button>
|
|
|
|
<p class="warning-text">
|
|
⚠️ Calibration changes are saved after 12 seconds. Do not turn off during this time.
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
-->
|
|
|
|
<!-- Actions -->
|
|
<div class="actions">
|
|
<button class="btn-danger" on:click={retract}>
|
|
<span class="icon">↓</span>
|
|
Retract Elements
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.card {
|
|
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%);
|
|
border-radius: 16px;
|
|
padding: 16px;
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 24px;
|
|
padding-bottom: 16px;
|
|
border-bottom: 2px solid rgba(79, 195, 247, 0.3);
|
|
}
|
|
|
|
.header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
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;
|
|
font-weight: 600;
|
|
background: linear-gradient(135deg, #4fc3f7 0%, #03a9f4 100%);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
|
|
}
|
|
|
|
h3 {
|
|
margin: 0 0 12px 0;
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
color: #4fc3f7;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: #4caf50;
|
|
box-shadow: 0 0 12px rgba(76, 175, 80, 0.8);
|
|
animation: pulse 2s ease-in-out infinite;
|
|
}
|
|
|
|
.status-dot.disconnected {
|
|
background: #666;
|
|
box-shadow: none;
|
|
animation: none;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.6; }
|
|
}
|
|
|
|
.metrics {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
/* Status Grid */
|
|
.status-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 10px;
|
|
}
|
|
|
|
.status-item {
|
|
background: rgba(15, 23, 42, 0.6);
|
|
padding: 16px;
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
|
}
|
|
|
|
.status-label {
|
|
font-size: 12px;
|
|
color: rgba(255, 255, 255, 0.6);
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.status-value {
|
|
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: 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: 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: 12px;
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.dir-btn {
|
|
padding: 12px 16px;
|
|
border: 2px solid rgba(79, 195, 247, 0.3);
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
background: rgba(79, 195, 247, 0.08);
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.dir-btn:hover {
|
|
border-color: rgba(79, 195, 247, 0.6);
|
|
color: rgba(255, 255, 255, 0.9);
|
|
background: rgba(79, 195, 247, 0.15);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.dir-btn.active {
|
|
border-color: #4fc3f7;
|
|
color: #4fc3f7;
|
|
background: rgba(79, 195, 247, 0.2);
|
|
box-shadow: 0 0 20px rgba(79, 195, 247, 0.4);
|
|
font-weight: 700;
|
|
}
|
|
|
|
/* Progress Section */
|
|
.progress-section {
|
|
background: rgba(79, 195, 247, 0.1);
|
|
padding: 16px;
|
|
border-radius: 8px;
|
|
border: 2px solid rgba(79, 195, 247, 0.3);
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.progress-section h3 {
|
|
margin: 0 0 12px 0;
|
|
font-size: 14px;
|
|
color: #4fc3f7;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.progress-bar {
|
|
height: 20px;
|
|
background: rgba(15, 23, 42, 0.6);
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #4fc3f7, #03a9f4);
|
|
transition: width 0.3s ease;
|
|
border-radius: 10px;
|
|
}
|
|
|
|
.progress-text {
|
|
text-align: center;
|
|
font-size: 13px;
|
|
color: rgba(255, 255, 255, 0.8);
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
}
|
|
|
|
</style> |