ultrabeam
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
import TunerGenius from './components/TunerGenius.svelte';
|
||||
import AntennaGenius from './components/AntennaGenius.svelte';
|
||||
import RotatorGenius from './components/RotatorGenius.svelte';
|
||||
import Ultrabeam from './components/Ultrabeam.svelte';
|
||||
|
||||
let status = null;
|
||||
let isConnected = false;
|
||||
@@ -118,6 +119,10 @@
|
||||
<AntennaGenius status={status?.antenna_genius} />
|
||||
<RotatorGenius status={status?.rotator_genius} />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<Ultrabeam status={status?.ultrabeam} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
547
web/src/components/Ultrabeam.svelte
Normal file
547
web/src/components/Ultrabeam.svelte
Normal file
@@ -0,0 +1,547 @@
|
||||
<script>
|
||||
import { api } from '../lib/api.js';
|
||||
|
||||
export let status;
|
||||
|
||||
$: 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';
|
||||
|
||||
// Band names mapping
|
||||
const bandNames = [
|
||||
'160M', '80M', '60M', '40M', '30M', '20M',
|
||||
'17M', '15M', '12M', '10M', '6M'
|
||||
];
|
||||
|
||||
// Direction names
|
||||
const directionNames = ['Normal', '180°', 'Bi-Dir'];
|
||||
|
||||
// Form state
|
||||
let targetFreq = 0;
|
||||
let targetDirection = 0;
|
||||
|
||||
// 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;
|
||||
}
|
||||
try {
|
||||
await api.ultrabeam.setFrequency(targetFreq, targetDirection);
|
||||
} catch (err) {
|
||||
console.error('Failed to set frequency:', err);
|
||||
alert('Failed to set frequency');
|
||||
}
|
||||
}
|
||||
|
||||
async function retract() {
|
||||
if (!confirm('Retract all antenna elements?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.ultrabeam.retract();
|
||||
} catch (err) {
|
||||
console.error('Failed to retract:', err);
|
||||
alert('Failed to retract');
|
||||
}
|
||||
}
|
||||
|
||||
async function adjustElement() {
|
||||
try {
|
||||
const newLength = elementLengths[selectedElement] + elementAdjustment;
|
||||
// TODO: Add API call when backend supports it
|
||||
alert(`Would adjust element ${selectedElement} by ${elementAdjustment}mm to ${newLength}mm`);
|
||||
elementAdjustment = 0;
|
||||
} catch (err) {
|
||||
console.error('Failed to adjust element:', err);
|
||||
alert('Failed to adjust element');
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate progress percentage
|
||||
$: progressPercent = progressTotal > 0 ? (progressCurrent / 60) * 100 : 0;
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Ultrabeam VL2.3</h2>
|
||||
<span class="status-dot" class:disconnected={!connected}></span>
|
||||
</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">{bandNames[band] || 'Unknown'}</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>
|
||||
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" on:click={setFrequency}>
|
||||
<span class="icon">📡</span>
|
||||
Set Frequency
|
||||
</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 -->
|
||||
<div class="elements-section">
|
||||
<h3>Element Lengths (mm)</h3>
|
||||
<div class="elements-grid">
|
||||
{#each elementLengths as length, i}
|
||||
{#if length > 0}
|
||||
<div class="element-item">
|
||||
<div class="element-label">Element {i + 1}</div>
|
||||
<div class="element-value">{length} mm</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calibration Mode -->
|
||||
<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 as length, i}
|
||||
{#if length > 0}
|
||||
<option value={i}>Element {i + 1} ({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: 24px;
|
||||
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);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
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: 24px;
|
||||
}
|
||||
|
||||
/* Status Grid */
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.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: 20px;
|
||||
font-weight: 600;
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
.status-value.freq {
|
||||
color: #66bb6a;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.status-value.band {
|
||||
color: #ffa726;
|
||||
}
|
||||
|
||||
.status-value.direction {
|
||||
color: #ab47bc;
|
||||
}
|
||||
|
||||
/* Control Section */
|
||||
.control-section {
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
.freq-control {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
input[type="number"],
|
||||
select {
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border: 1px solid rgba(79, 195, 247, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
input[type="number"]:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: #4fc3f7;
|
||||
box-shadow: 0 0 12px rgba(79, 195, 247, 0.3);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #4fc3f7 0%, #03a9f4 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(79, 195, 247, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(79, 195, 247, 0.6);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #f44336 0%, #d32f2f 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(244, 67, 54, 0.4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(244, 67, 54, 0.6);
|
||||
}
|
||||
|
||||
.btn-caution {
|
||||
background: linear-gradient(135deg, #ffa726 0%, #fb8c00 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(255, 167, 38, 0.4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-caution:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(255, 167, 38, 0.6);
|
||||
}
|
||||
|
||||
.btn-toggle {
|
||||
background: rgba(79, 195, 247, 0.1);
|
||||
color: #4fc3f7;
|
||||
border: 1px solid rgba(79, 195, 247, 0.3);
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-toggle.active {
|
||||
background: rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
.progress-section {
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin: 12px 0;
|
||||
border: 1px solid rgba(79, 195, 247, 0.3);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4fc3f7 0%, #66bb6a 100%);
|
||||
transition: width 0.3s ease;
|
||||
box-shadow: 0 0 12px rgba(79, 195, 247, 0.6);
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
text-align: center;
|
||||
color: #4fc3f7;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Elements */
|
||||
.elements-section {
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
.elements-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.element-item {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.element-label {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.element-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
/* Calibration */
|
||||
.calibration-section {
|
||||
background: rgba(255, 152, 0, 0.05);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 152, 0, 0.3);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.calibration-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #ffa726;
|
||||
color: #ffa726;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.freq-control {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.elements-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -89,4 +89,13 @@ export const api = {
|
||||
rotateCCW: () => request('/rotator/ccw', { method: 'POST' }),
|
||||
stop: () => request('/rotator/stop', { method: 'POST' }),
|
||||
},
|
||||
|
||||
// Ultrabeam
|
||||
ultrabeam: {
|
||||
setFrequency: (frequency, direction) => request('/ultrabeam/frequency', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ frequency, direction }),
|
||||
}),
|
||||
retract: () => request('/ultrabeam/retract', { method: 'POST' }),
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user