feat: For steppir & Ultrabeam,possibility to change each element length

This commit is contained in:
2026-07-16 22:32:29 +02:00
parent 69229964f4
commit 7cf2dfeaf9
6 changed files with 115 additions and 32 deletions
+69 -29
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine } from 'lucide-react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -11,7 +11,7 @@ import { RotorCompass } from '@/components/RotorCompass';
import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
} from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
@@ -97,17 +97,44 @@ type AntStatus = { enabled: boolean; type: string; connected: boolean; direction
// MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the
// Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam
// only — per-element length adjustment. Heading/state is polled by the panel.
const ELEMENT_STEP = 2; // the physical console adjusts 2 mm per press
// elementName maps an element index to a ham-radio name: 0 = reflector,
// 1 = driven element, then Director 1, 2, 3…
function elementName(i: number, t: (k: string, v?: any) => string): string {
if (i === 0) return t('station.reflector');
if (i === 1) return t('station.driven');
return `${t('station.director')} ${i - 1}`;
}
function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () => void; t: (k: string, v?: any) => string }) {
const [err, setErr] = useState('');
const [elNum, setElNum] = useState('1'); // element to adjust (1..6)
const [elLen, setElLen] = useState(''); // target length (mm)
const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements
const [reading, setReading] = useState(false);
const [busyEl, setBusyEl] = useState<number | null>(null);
const run = (p: Promise<any>) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e)));
const setElement = () => {
const n = parseInt(elNum, 10), mm = parseInt(elLen, 10);
if (!Number.isFinite(n) || !Number.isFinite(mm)) return;
run(MotorSetElement(n - 1, mm)); // protocol elements are 0-based (0..5)
};
const isUB = ant.type !== 'steppir';
const readLengths = useCallback(async () => {
setReading(true); setErr('');
try { setLengths(((await MotorReadElements()) ?? []) as number[]); }
catch (e: any) { setErr(String(e?.message ?? e)); }
finally { setReading(false); }
}, []);
// Read the current lengths once when the Ultrabeam widget mounts/connects, so
// +/- starts from the real values rather than blind.
useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]);
// Nudge one element by ±2 mm from its current known length.
const nudge = async (i: number, delta: number) => {
const cur = lengths[i] ?? 0;
const next = Math.max(0, cur + delta);
setBusyEl(i); setErr('');
setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic
try { await MotorSetElement(i, next); refetch(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
finally { setBusyEl(null); }
};
const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]];
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
@@ -143,26 +170,39 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
{isUB && (
<div>
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.elements')}</div>
{ant.elements.length > 0 && (
<div className="flex flex-wrap gap-1 mb-1.5">
{ant.elements.map((mm, i) => (
<span key={i} className="text-[10px] font-mono rounded bg-muted px-1.5 py-0.5">{t('station.element')} {i + 1}: {mm}mm</span>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('station.elements')}</span>
<button type="button" onClick={readLengths} disabled={!ant.connected || reading}
className="text-[10px] text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-40" title={t('station.readLengths')}>
<RefreshCw className={cn('size-3', reading && 'animate-spin')} /> {t('station.read')}
</button>
</div>
{lengths.length === 0 ? (
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
) : (
<div className="space-y-1.5">
{/* Hide 0-length elements — an Ultrabeam reports 6 slots but a
3-element beam only uses the first few. */}
{lengths.map((mm, i) => ({ mm, i })).filter((e) => e.mm > 0).map(({ mm, i }) => (
<div key={i} className="flex items-center gap-2">
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
<button type="button" disabled={!ant.connected || busyEl !== null}
onClick={() => nudge(i, -ELEMENT_STEP)}
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
<Minus className="size-3.5" />
</button>
<span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums">
{busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`}
</span>
<button type="button" disabled={!ant.connected || busyEl !== null}
onClick={() => nudge(i, ELEMENT_STEP)}
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
<Plus className="size-3.5" />
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-1.5">
<Select value={elNum} onValueChange={setElNum}>
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6].map((n) => <SelectItem key={n} value={String(n)}>{t('station.element')} {n}</SelectItem>)}
</SelectContent>
</Select>
<Input value={elLen} placeholder="mm" className="h-8 w-20 font-mono text-sm" disabled={!ant.connected}
onChange={(e) => setElLen(e.target.value.replace(/[^0-9]/g, ''))}
onKeyDown={(e) => { if (e.key === 'Enter') setElement(); }} />
<Button size="sm" className="h-8" disabled={!ant.connected || !elLen} onClick={setElement}>{t('station.set')}</Button>
</div>
<p className="text-[10px] text-muted-foreground mt-1">{t('station.elementsHint')}</p>
</div>
)}
@@ -315,7 +355,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
return (
<div className="flex-1 min-h-0 overflow-auto p-4">
<div className="flex items-center justify-between mb-3 max-w-4xl">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
@@ -328,7 +368,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div>
)}
<div className="grid gap-3 max-w-4xl md:grid-cols-2 items-start">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 items-start">
{ordered.map((w) => (
<div key={w.id} draggable
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
@@ -341,7 +381,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div>
{!noDevices && (
<p className="text-[11px] text-muted-foreground mt-2 max-w-4xl">{t('station.dragHint')}</p>
<p className="text-[11px] text-muted-foreground mt-2">{t('station.dragHint')}</p>
)}
{editing && (