feat+fix batch: Station Control dashboard (fixed-width panels, grip-handle drag reorder, amp meters from the Flex stream, compact relays), Ultrabeam element list capped to 3 (READ_BANDS over-read), spot-click on Flex panadapter now sets mode, DAX TX toggle, DVK panel state persists across relaunch, duplicate amp meters cleared on power-cycle, QSO audio recording snapshot taken synchronously at log time (was racing the form-clear cancel), Flex COMP meter max -20->-25 and MIC scale to 0; changelog 0.20.10

This commit is contained in:
2026-07-23 01:08:47 +02:00
parent 13de53772b
commit 1070637c40
7 changed files with 142 additions and 62 deletions
+17 -10
View File
@@ -1983,6 +1983,12 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
q.ID = id q.ID = id
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh (in-memory) a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh (in-memory)
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async) a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
// Snapshot the QSO recording SYNCHRONOUSLY, BEFORE announcing the log: the
// qso:logged event clears the entry form, and clearing the callsign cancels
// the recorder — so if this ran on the async path below it raced the cancel
// and the audio was discarded (recordings silently stopped working). The
// snapshot is an in-memory copy; the heavy file encode still runs async.
a.saveQSORecording(&q)
// Announce the log RIGHT AWAY so the grid/UI refresh at once and the entry // Announce the log RIGHT AWAY so the grid/UI refresh at once and the entry
// form clears immediately — the operator is not made to wait on the DB. // form clears immediately — the operator is not made to wait on the DB.
wruntime.EventsEmit(a.ctx, "qso:logged", id) wruntime.EventsEmit(a.ctx, "qso:logged", id)
@@ -1994,7 +2000,6 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
qc := q qc := q
go func() { go func() {
a.materializeAwardRefs(qc) // fills the award_refs column a.materializeAwardRefs(qc) // fills the award_refs column
a.saveQSORecording(&qc)
if a.extsvc != nil { if a.extsvc != nil {
a.extsvc.OnQSOLogged(id) a.extsvc.OnQSOLogged(id)
} }
@@ -6226,19 +6231,14 @@ func (a *App) saveQSORecording(q *qso.QSO) {
return return
} }
// Stamp the recording's path on the QSO now, synchronously, so it's set // Stamp the recording's path on the in-memory QSO now (the encode goroutine
// before the eQSL auto-send reads the QSO (their full-row Updates would // reads it). The DB write is deferred into that goroutine so this stays OFF
// otherwise race and clobber each other's extras). // the critical logging path — saveQSORecording is now called synchronously.
if q.ID != 0 { if q.ID != 0 {
if q.Extras == nil { if q.Extras == nil {
q.Extras = map[string]string{} q.Extras = map[string]string{}
} }
q.Extras["APP_OPSLOG_RECORDING"] = name // in-memory copy for the encode goroutine q.Extras["APP_OPSLOG_RECORDING"] = name
// Persist ONLY this extras key (targeted) — a full-row Update from this
// in-memory copy could revert a column a concurrent post-log action changed.
if err := a.qso.SetExtra(a.ctx, q.ID, "APP_OPSLOG_RECORDING", name); err != nil {
applog.Printf("qso-rec: store recording path: %v", err)
}
} }
// Clone the QSO for the goroutine with its OWN copy of the Extras map: a // Clone the QSO for the goroutine with its OWN copy of the Extras map: a
@@ -6264,6 +6264,13 @@ func (a *App) saveQSORecording(q *qso.QSO) {
applog.Printf("qso-rec: save failed: %v", err) applog.Printf("qso-rec: save failed: %v", err)
return return
} }
// Persist the recording path (targeted extras write) now that the file
// exists — off the critical path, so a busy MySQL never delays logging.
if qc.ID != 0 {
if err := a.qso.SetExtra(a.ctx, qc.ID, "APP_OPSLOG_RECORDING", name); err != nil {
applog.Printf("qso-rec: store recording path: %v", err)
}
}
applog.Printf("qso-rec: saved %s", path) applog.Printf("qso-rec: saved %s", path)
// Auto-send the recording once the file exists. Gated ONLY on its own // Auto-send the recording once the file exists. Gated ONLY on its own
// "auto-send recording" toggle (email.auto_send) — NOT the SMTP panel's // "auto-send recording" toggle (email.auto_send) — NOT the SMTP panel's
+14 -2
View File
@@ -3,10 +3,22 @@
"version": "0.20.10", "version": "0.20.10",
"date": "2026-07-22", "date": "2026-07-22",
"en": [ "en": [
"Clicking an OpsLog spot on the FlexRadio panadapter now switches the mode too, not just the frequency: cluster spots (which carry no mode) get one inferred from the comment or the band plan, and every spot's mode is sent to the radio as a real SmartSDR mode (SSB resolves to USB/LSB by frequency), so the click tunes AND sets the mode." "Clicking an OpsLog spot on the FlexRadio panadapter now switches the mode too, not just the frequency: cluster spots (which carry no mode) get one inferred from the comment or the band plan, and every spot's mode is sent to the radio as a real SmartSDR mode (SSB resolves to USB/LSB by frequency), so the click tunes AND sets the mode.",
"Station Control is now a clean dashboard of fixed-width panels that wrap to fill the window. Each panel has a grip handle on its left edge — drag it to move the panel anywhere, and the order is remembered. Relay buttons are compact. The amplifier panel now shows the same live meters (forward power, drain current, PA temperature) as the FlexRadio panel.",
"Station Control: the Ultrabeam element list now shows the right number of elements (3 for a 3-element beam) instead of extra bogus rows that came from over-reading the controller's reply.",
"Fixed the FlexRadio amplifier meters showing twice after the amp was power-cycled — the old (stale) meters are now cleared when the amplifier is torn down, so only the fresh set remains.",
"Fixed QSO audio recordings silently not being saved: the recording snapshot had moved onto the background (post-log) path, where it raced the entry-form clearing that cancels the recorder — so the audio was discarded before it was captured. The snapshot is now taken the instant you log, before the form clears; the file encoding still happens in the background.",
"The Digital Voice Keyer (DVK) panel now stays open across a relaunch if you had it open — it used to reset to closed every time.",
"FlexRadio meter scales now match SmartSDR: MIC (level) tops out at 0 dB and COMP (compression) tops out at -25 dB (was -20)."
], ],
"fr": [ "fr": [
"Cliquer sur un spot OpsLog du panadapter FlexRadio change maintenant aussi le mode, plus seulement la fréquence : les spots cluster (qui n'ont pas de mode) en reçoivent un déduit du commentaire ou du plan de bande, et le mode de chaque spot est envoyé à la radio comme un vrai mode SmartSDR (SSB devient USB/LSB selon la fréquence), donc le clic accorde ET règle le mode." "Cliquer sur un spot OpsLog du panadapter FlexRadio change maintenant aussi le mode, plus seulement la fréquence : les spots cluster (qui n'ont pas de mode) en reçoivent un déduit du commentaire ou du plan de bande, et le mode de chaque spot est envoyé à la radio comme un vrai mode SmartSDR (SSB devient USB/LSB selon la fréquence), donc le clic accorde ET règle le mode.",
"Station Control est maintenant un tableau de bord de panneaux à largeur fixe qui s'alignent pour remplir la fenêtre. Chaque panneau a une poignée sur son bord gauche — glisse-la pour déplacer le panneau où tu veux, et l'ordre est mémorisé. Les boutons relais sont compacts. Le panneau amplificateur affiche maintenant les mêmes mesures en direct (puissance directe, courant de drain, température PA) que le panneau FlexRadio.",
"Station Control : la liste des éléments Ultrabeam affiche maintenant le bon nombre d'éléments (3 pour une beam 3 éléments) au lieu de lignes fantômes issues d'une sur-lecture de la réponse du contrôleur.",
"Correction des mesures de l'amplificateur FlexRadio affichées en double après un cycle d'alimentation de l'ampli — les anciennes mesures (périmées) sont maintenant effacées quand l'amplificateur est retiré, seul le jeu à jour reste.",
"Correction des enregistrements audio des QSO qui n'étaient plus sauvegardés : la capture de l'enregistrement était passée sur le chemin d'arrière-plan (après le log), où elle entrait en concurrence avec le vidage du formulaire qui annule l'enregistreur — l'audio était donc jeté avant d'être capturé. La capture se fait maintenant à l'instant du log, avant le vidage du formulaire ; l'encodage du fichier reste en arrière-plan.",
"Le panneau Digital Voice Keyer (DVK) reste maintenant ouvert après un relancement si vous l'aviez ouvert — il se refermait à chaque démarrage.",
"Les échelles des meters FlexRadio collent maintenant à SmartSDR : MIC (niveau) plafonne à 0 dB et COMP (compression) à -25 dB (au lieu de -20)."
] ]
}, },
{ {
+4 -1
View File
@@ -950,7 +950,10 @@ export default function App() {
const [contestTabEnabled, setContestTabEnabled] = useState(() => localStorage.getItem('opslog.contestTab') === '1'); const [contestTabEnabled, setContestTabEnabled] = useState(() => localStorage.getItem('opslog.contestTab') === '1');
useEffect(() => { localStorage.setItem('opslog.contestTab', contestTabEnabled ? '1' : '0'); }, [contestTabEnabled]); useEffect(() => { localStorage.setItem('opslog.contestTab', contestTabEnabled ? '1' : '0'); }, [contestTabEnabled]);
const [dvkEnabled, setDvkEnabled] = useState(false); // DVK panel open/closed — persisted like the other Tools toggles above so it
// survives a relaunch instead of resetting to closed.
const [dvkEnabled, setDvkEnabled] = useState(() => localStorage.getItem('opslog.dvkEnabled') === '1');
useEffect(() => { localStorage.setItem('opslog.dvkEnabled', dvkEnabled ? '1' : '0'); }, [dvkEnabled]);
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]); const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 }); const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
const dvkActiveRef = useRef(false); const dvkActiveRef = useRef(false);
+5 -4
View File
@@ -555,11 +555,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} /> value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
); })(), ); })(),
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />, swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
// Mic input level: fixed -40..+10 dB scale, RED from 0 dB up (overdrive). // Mic input level in dBFS — SmartSDR's scale is -40…0 dB.
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={10} accent="#16a34a" mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={0} accent="#16a34a"
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />, segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
// Speech compression (dB of gain reduction). // Speech compression — original working meter, only the top of the
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={(comp.hi && comp.hi > 0) ? comp.hi : 25} accent="#0891b2" />, // scale changed from the radio-reported 20 to 25 (SmartSDR's -25 max).
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={25} accent="#0891b2" />,
].filter(Boolean); ].filter(Boolean);
return ( return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
+63 -38
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, Flame } from 'lucide-react'; import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, Flame, GripVertical } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -206,9 +206,11 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p> <p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
{/* Hide 0-length elements — an Ultrabeam reports 6 slots but a {/* The READ_BANDS reply is undocumented and its 16-bit parse picks
3-element beam only uses the first few. */} up structural bytes past the real data (varying by band), which
{lengths.map((mm, i) => ({ mm, i })).filter((e) => e.mm > 0).map(({ mm, i }) => ( made 6+ bogus "elements" appear. Ultrabeam beams are 3-element,
and ModifyElement addresses elements 0..2 — so show just those. */}
{lengths.map((mm, i) => ({ mm, i })).slice(0, 3).map(({ mm, i }) => (
<div key={i} className="flex items-center gap-2"> <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> <span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
<button type="button" disabled={!ant.connected || busyEl !== null} <button type="button" disabled={!ant.connected || busyEl !== null}
@@ -304,6 +306,7 @@ function AmplifierWidget({ t }: { t: (k: string, v?: any) => string }) {
</div> </div>
<div className="p-3 space-y-2"> <div className="p-3 space-y-2">
{isPGXL ? ( {isPGXL ? (
<>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<button type="button" disabled={!st.connected} <button type="button" disabled={!st.connected}
onClick={doOperate} onClick={doOperate}
@@ -321,6 +324,37 @@ function AmplifierWidget({ t }: { t: (k: string, v?: any) => string }) {
))} ))}
<span className="text-xs text-muted-foreground font-mono">{st.state || ''}{st.temperature ? ` · ${Math.round(st.temperature)}°C` : ''}</span> <span className="text-xs text-muted-foreground font-mono">{st.state || ''}{st.temperature ? ` · ${Math.round(st.temperature)}°C` : ''}</span>
</div> </div>
{/* Live amp meters (FWD / ID / TEMP …) from the FlexRadio UDP stream, so
this card matches the amplifier card in the Flex panel. */}
{viaFlex && (() => {
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
const ampMeters = ((flex?.meters as any[]) || []).filter((m) => (m.src || '').toUpperCase().includes('AMP') && !/^(RL|DRV)$/i.test((m.name || '').trim()));
if (ampMeters.length === 0) return null;
return (
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 pt-1">
{ampMeters.map((m) => {
const isPwr = /fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '');
const val = isPwr ? dbmToW(m.value) : m.value;
const unit = isPwr ? 'W' : (m.unit || '');
const hi = isPwr ? 2000 : (/amp/i.test(m.unit || '') || /^ID$/i.test((m.name || '').trim())) ? 25 : (m.hi > 0 ? m.hi : 100);
const frac = Math.min(1, Math.max(0, val / hi));
const acc = isPwr ? '#dc2626' : /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
return (
<div key={m.id}>
<div className="flex items-baseline justify-between text-[10px]">
<span className="text-muted-foreground uppercase tracking-wider truncate">{m.name}</span>
<span className="font-mono font-bold tabular-nums text-foreground/90">{val.toFixed(isPwr ? 0 : 1)}<span className="text-muted-foreground ml-0.5">{unit}</span></span>
</div>
<div className="h-1.5 rounded bg-muted overflow-hidden mt-0.5">
<div className="h-full transition-all" style={{ width: `${frac * 100}%`, background: acc }} />
</div>
</div>
);
})}
</div>
);
})()}
</>
) : ( ) : (
<> <>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@@ -393,7 +427,6 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
}); });
const dragId = useRef<string | null>(null); const dragId = useRef<string | null>(null);
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
// Amplifiers (Settings → Amplifier): shown here so operators without a // Amplifiers (Settings → Amplifier): shown here so operators without a
// FlexRadio panel still get the controls. Re-read every 5s so enabling an // FlexRadio panel still get the controls. Re-read every 5s so enabling an
// amp in Settings makes the widget appear without reopening the tab. // amp in Settings makes the widget appear without reopening the tab.
@@ -491,25 +524,26 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')} <button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button> onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
</div> </div>
<div className="p-3 grid grid-cols-2 gap-2"> {/* Compact one-line relay buttons at a FIXED width so they don't stretch
across the whole card — they wrap to fill the available width instead. */}
<div className="p-2 flex flex-wrap gap-1.5">
{relays.map((r) => { {relays.map((r) => {
const key = `${dev.id}:${r.number}`; const key = `${dev.id}:${r.number}`;
const label = r.label || `${t('station.relay')} ${r.number}`; const label = r.label || `${t('station.relay')} ${r.number}`;
return ( return (
<button key={r.number} type="button" disabled={!st?.connected} <button key={r.number} type="button" disabled={!st?.connected}
title={label}
onClick={() => toggle(dev, r.number, !r.on)} onClick={() => toggle(dev, r.number, !r.on)}
className={cn('flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors disabled:opacity-40', className={cn('w-[150px] flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors disabled:opacity-40',
r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}> r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
<span className={cn('flex items-center justify-center size-7 rounded-md shrink-0', <span className={cn('flex items-center justify-center size-5 rounded shrink-0',
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}> r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
{busy[key] ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />} {busy[key] ? <Loader2 className="size-3 animate-spin" /> : <Power className="size-3" />}
</span> </span>
<span className="min-w-0"> <span className="flex-1 min-w-0 text-xs font-medium truncate">{label}</span>
<span className="block text-xs font-medium truncate">{label}</span> <span className={cn('text-[9px] font-bold shrink-0', r.on ? 'text-success' : 'text-muted-foreground/50')}>
<span className={cn('block text-[10px] font-semibold', r.on ? 'text-success' : 'text-muted-foreground')}>
{r.on ? t('station.on') : t('station.off')} {r.on ? t('station.on') : t('station.off')}
</span> </span>
</span>
</button> </button>
); );
})} })}
@@ -536,33 +570,14 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && ampCount === 0; const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && ampCount === 0;
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
// fill however many columns are available.
const gridCols: Record<string, string> = {
auto: 'sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4',
'1': 'grid-cols-1', '2': 'grid-cols-2', '3': 'grid-cols-3', '4': 'grid-cols-4',
};
return ( return (
<div className="flex-1 min-h-0 overflow-auto p-4"> <div className="flex-1 min-h-0 overflow-auto p-4">
<div className="flex items-center justify-between mb-3"> <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> <h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
<div className="flex items-center gap-2">
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
{(['auto', '2', '3', '4'] as const).map((c) => (
<button key={c} type="button"
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
{c === 'auto' ? t('station.colsAuto') : c}
</button>
))}
</div>
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}> <Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')} <Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
</Button> </Button>
</div> </div>
</div>
{noDevices && !editing && ( {noDevices && !editing && (
<div className="max-w-4xl rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground"> <div className="max-w-4xl rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
@@ -570,14 +585,24 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div> </div>
)} )}
<div className={cn('grid gap-3 items-start', gridCols[cols] ?? gridCols.auto)}> {/* Dashboard of FIXED-WIDTH cards that wrap to fill the window — a clean,
evenly-sized grid. Each card has a grip handle (left rail) as the drag
initiator (the card body is full of buttons, so dragging the whole card
was unreliable). Drop on another card to reorder. */}
<div className="flex flex-wrap gap-4 items-start">
{ordered.map((w) => ( {ordered.map((w) => (
<div key={w.id} draggable <div key={w.id} className="flex items-stretch w-[430px]"
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
<div draggable
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }} onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }} onDragEnd={() => { dragId.current = null; }}
onDrop={(e) => { e.preventDefault(); onDrop(w.id); }} title={t('station.dragMove')}
className={cn('cursor-grab active:cursor-grabbing', dragId.current === w.id && 'opacity-60')}> className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
{w.node} dragId.current === w.id && 'opacity-60')}>
<GripVertical className="size-4" />
</div>
<div className="flex-1 min-w-0">{w.node}</div>
</div> </div>
))} ))}
</div> </div>
+2 -2
View File
@@ -129,7 +129,7 @@ const en: Dict = {
'uscty.backfillIntro': 'Resolve county (and grid) for US QSOs already in your log that are missing them. Existing values are kept — only blanks are filled.', 'uscty.backfillIntro': 'Resolve county (and grid) for US QSOs already in your log that are missing them. Existing values are kept — only blanks are filled.',
'uscty.backfillRun': 'Fill missing counties', 'uscty.backfillRun': 'Fill missing counties',
'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.', 'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.',
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.', 'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag the grip handle on the left of a panel to move it. Pick a column count to lay them out in a grid.', 'station.dragMove': 'Drag to move this panel', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.',
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer', 'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplifier', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices', 'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplifier', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
'sec.relayauto': 'Relay auto-control', 'sec.relayauto': 'Relay auto-control',
@@ -442,7 +442,7 @@ const fr: Dict = {
'uscty.backfillIntro': "Résout le comté (et le locator) pour les QSO US déjà dans ton log qui n'en ont pas. Les valeurs existantes sont conservées — seuls les vides sont remplis.", 'uscty.backfillIntro': "Résout le comté (et le locator) pour les QSO US déjà dans ton log qui n'en ont pas. Les valeurs existantes sont conservées — seuls les vides sont remplis.",
'uscty.backfillRun': 'Remplir les comtés manquants', 'uscty.backfillRun': 'Remplir les comtés manquants',
'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.', 'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.',
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).', 'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse la poignée à gauche d un panneau pour le déplacer. Choisis un nombre de colonnes pour la disposition.', 'station.dragMove': 'Glisser pour déplacer ce panneau', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).',
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW', 'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplificateur', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio', 'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplificateur', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
'sec.relayauto': 'Relais automatiques', 'sec.relayauto': 'Relais automatiques',
+32
View File
@@ -649,6 +649,18 @@ func (f *Flex) handleStatus(payload string) {
} }
if removed { if removed {
f.amp = flexAmp{} f.amp = flexAmp{}
// The amp's meters (FWD / ID / TEMP …) don't always get an individual
// "meter removed" push, so power-cycling the amp left the OLD meter
// ids in the maps while the re-registered amp added NEW ones — the
// panel then showed every amp meter twice. Drop every AMP-sourced
// meter here so only the fresh set survives.
for id, mi := range f.meterMeta {
if strings.Contains(strings.ToUpper(mi.src), "AMP") {
delete(f.meterMeta, id)
delete(f.meterVal, id)
delete(f.meterSub, id)
}
}
} }
f.mu.Unlock() f.mu.Unlock()
} }
@@ -660,6 +672,26 @@ func (f *Flex) handleStatus(payload string) {
f.meterRawLogged = true f.meterRawLogged = true
debugLog.Printf("Flex: meter status raw: %s", payload) debugLog.Printf("Flex: meter status raw: %s", payload)
} }
// Meter removal — "meter <id> removed": drop it so a stale value can't
// linger (e.g. after an amp/slice is torn down).
removedMeter := false
for _, tok := range fields[1:] {
if tok == "removed" || tok == "in_use=0" {
removedMeter = true
}
}
if removedMeter {
f.mu.Lock()
for _, tok := range fields[1:] {
if n, err := strconv.Atoi(strings.TrimSpace(tok)); err == nil {
delete(f.meterMeta, n)
delete(f.meterVal, n)
delete(f.meterSub, n)
}
}
f.mu.Unlock()
return
}
var newIDs []int var newIDs []int
f.mu.Lock() f.mu.Lock()
for _, tok := range fields[1:] { for _, tok := range fields[1:] {