ultrabeam

This commit is contained in:
2026-01-10 11:01:40 +01:00
parent 5fd81a641d
commit f172678560
11 changed files with 1118 additions and 2 deletions

View File

@@ -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>

View 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>

View File

@@ -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' }),
},
};