chore: release v0.22.9

This commit is contained in:
2026-08-02 06:40:10 +02:00
parent 296a4a55c0
commit f6cd6e999a
38 changed files with 1308 additions and 297 deletions
+8 -2
View File
@@ -14790,8 +14790,14 @@ func (a *App) AmpOperate(id string, on bool) error {
} }
// AmpPower turns the given amp on/off (SPE and ACOM; the PGXL has no power // AmpPower turns the given amp on/off (SPE and ACOM; the PGXL has no power
// command on its direct link). // command on its direct link). Logged here because the UI buttons swallow the
func (a *App) AmpPower(id string, on bool) error { // returned error — a failed power-on otherwise looks like a dead button.
func (a *App) AmpPower(id string, on bool) (err error) {
defer func() {
if err != nil {
applog.Printf("amp %s: power %v failed: %v", id, on, err)
}
}()
inst := a.ampInstByID(id) inst := a.ampInstByID(id)
if inst == nil { if inst == nil {
return fmt.Errorf("amplifier not running — check Settings → Amplifier") return fmt.Errorf("amplifier not running — check Settings → Amplifier")
+14 -2
View File
@@ -5,12 +5,24 @@
"en": [ "en": [
"Awards: the CW / Phone / Digital filter now applies to the whole award — bands, counts, confirmations and the contacts behind a cell — instead of only hiding rows from the reference list.", "Awards: the CW / Phone / Digital filter now applies to the whole award — bands, counts, confirmations and the contacts behind a cell — instead of only hiding rows from the reference list.",
"Yaesu and Kenwood: a new option lowers the DTR and RTS lines on connect, for interfaces that read either as PTT and keep the radio transmitting while OpsLog is open. Off by default — lowering them stops other interfaces transmitting at all.", "Yaesu and Kenwood: a new option lowers the DTR and RTS lines on connect, for interfaces that read either as PTT and keep the radio transmitting while OpsLog is open. Off by default — lowering them stops other interfaces transmitting at all.",
"Rig panels: the mouse wheel only moves a slider you have clicked on first. Scrolling the panel used to change whatever control the pointer happened to be over — including transmit power." "SPE amplifiers: the ON and OFF buttons now switch the amplifier on and off over USB.",
"Quieter diagnostic log: no more FlexRadio meter subscriptions, per-packet WSJT-X/MSHV lines, or repeated Tuner Genius / PGXL / SPE status frames.",
"Station Control: the cards now line up on a grid — same width, and same height across a row — and the column selector goes up to 6.",
"New amplifier widget beside the keyers (flame icon): Operate, power ON/OFF, the SPE power level and live watts / SWR / temperature. With several amplifiers a dropdown on the icon picks the one to watch, or all of them.",
"Icom: the meters and front-panel readings are polled only while the Icom console is on screen. They were half of the CI-V traffic at all times, which matters when the link is shared — a microHAM Router splitting one CI-V port between OpsLog and WSJT-X, for instance.",
"Icom: the power-ON button sends a wake preamble sized for the CI-V speed. It was a fixed length that only suited 19200 baud, so a radio set faster — 115200 is the default — ignored the command.",
"The Tuner Genius power meter now shows the peak the device itself measured, instead of whatever instant the poll happened to catch — a kilowatt transmission could read a few hundred watts. Every power meter also clears the moment the carrier drops, instead of trailing several seconds behind it."
], ],
"fr": [ "fr": [
"Diplômes : le filtre CW / Phonie / Numérique sapplique désormais à tout le diplôme — bandes, comptes, confirmations et QSO derrière une case — au lieu de masquer seulement des lignes de la liste des références.", "Diplômes : le filtre CW / Phonie / Numérique sapplique désormais à tout le diplôme — bandes, comptes, confirmations et QSO derrière une case — au lieu de masquer seulement des lignes de la liste des références.",
"Yaesu et Kenwood : une option abaisse les lignes DTR et RTS à la connexion, pour les interfaces qui les lisent comme PTT et laissent la radio en émission tant quOpsLog est ouvert. Désactivée par défaut — les abaisser empêche dautres interfaces d’émettre.", "Yaesu et Kenwood : une option abaisse les lignes DTR et RTS à la connexion, pour les interfaces qui les lisent comme PTT et laissent la radio en émission tant quOpsLog est ouvert. Désactivée par défaut — les abaisser empêche dautres interfaces d’émettre.",
"Panneaux radio : la molette nagit que sur un curseur préalablement cliqué. Faire défiler le panneau modifiait le réglage survolé par le pointeur — y compris la puissance d’émission." "Amplificateurs SPE : les boutons ON et OFF allument et éteignent désormais lamplificateur par lUSB.",
"Journal de diagnostic allégé : plus dabonnements aux mètres FlexRadio, de ligne par paquet WSJT-X/MSHV, ni de trames statut Tuner Genius / PGXL / SPE répétées.",
"Contrôle station : les cartes salignent désormais sur une grille — même largeur, et même hauteur sur une ligne — et le sélecteur de colonnes va jusqu’à 6.",
"Nouveau widget amplificateur à côté des manipulateurs (icône flamme) : Operate, marche/arrêt, le niveau de puissance SPE et les watts / ROS / température en direct. Avec plusieurs amplis, une liste déroulante sur licône choisit celui à surveiller, ou tous.",
"Icom : les mesures et les réglages de façade ne sont interrogés que lorsque la console Icom est affichée. Ils représentaient la moitié du trafic CI-V en permanence, ce qui compte quand la liaison est partagée — un Router microHAM qui répartit un port CI-V entre OpsLog et WSJT-X, par exemple.",
"Icom : le bouton de mise sous tension envoie un préambule de réveil dimensionné pour la vitesse CI-V. Sa longueur était fixe et ne convenait qu’à 19200 bauds, si bien quune radio réglée plus vite — 115200 par défaut — ignorait la commande.",
"Le mètre de puissance du Tuner Genius affiche désormais la crête mesurée par lappareil lui-même, au lieu de linstant capté par linterrogation — une émission dun kilowatt pouvait afficher quelques centaines de watts. Tous les mètres de puissance retombent aussi dès la fin de l’émission, au lieu de traîner plusieurs secondes."
] ]
}, },
{ {
+82 -4
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock, Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react'; } from 'lucide-react';
@@ -77,6 +77,7 @@ import { IcomPanel } from '@/components/IcomPanel';
import { YaesuPanel } from '@/components/YaesuPanel'; import { YaesuPanel } from '@/components/YaesuPanel';
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel'; import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel'; import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
import { AmpWidget } from '@/components/AmpWidget';
import { ScpPanel, type ScpResult } from '@/components/ScpPanel'; import { ScpPanel, type ScpResult } from '@/components/ScpPanel';
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder'; import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
import { AwardsPanel } from '@/components/AwardsPanel'; import { AwardsPanel } from '@/components/AwardsPanel';
@@ -570,6 +571,16 @@ export default function App() {
// PGXL's OPERATE state comes from the radio when it reports the amp. // PGXL's OPERATE state comes from the radio when it reports the amp.
const [ampSts, setAmpSts] = useState<any[]>([]); const [ampSts, setAmpSts] = useState<any[]>([]);
const [flexAmp, setFlexAmp] = useState<any>(null); const [flexAmp, setFlexAmp] = useState<any>(null);
// Amplifier widget: whether it is shown, and which amp — "all", or one amp id.
// Several amps side by side make a wide widget, so an operator running two
// picks the one he watches while transmitting. Declared here because the poll
// below runs faster while the widget is open.
const [showAmpWidget, setShowAmpWidget] = useState(() => localStorage.getItem('opslog.showAmpWidget') !== '0');
const [ampWidgetSel, setAmpWidgetSel] = useState(() => localStorage.getItem('opslog.ampSel.widget') || 'all');
// Poll fast only while the amplifier widget is open: its meters must track TX
// the way the FlexRadio panel's do (400 ms), or the power reads seconds behind
// the transmission and looks like the app is struggling. The status-bar chips
// alone need nothing like that rate.
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
const tick = () => Promise.all([ const tick = () => Promise.all([
@@ -577,9 +588,9 @@ export default function App() {
GetFlexState().catch(() => null), GetFlexState().catch(() => null),
]).then(([l, fx]: any[]) => { if (alive) { setAmpSts((l ?? []) as any[]); setFlexAmp(fx); } }); ]).then(([l, fx]: any[]) => { if (alive) { setAmpSts((l ?? []) as any[]); setFlexAmp(fx); } });
tick(); tick();
const id = window.setInterval(tick, 2000); const id = window.setInterval(tick, showAmpWidget ? 400 : 2000);
return () => { alive = false; window.clearInterval(id); }; return () => { alive = false; window.clearInterval(id); };
}, []); }, [showAmpWidget]);
// Multi-op "who's on air" widget: every operator's live status from the shared // Multi-op "who's on air" widget: every operator's live status from the shared
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook. // MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number }; type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
@@ -4849,6 +4860,60 @@ export default function App() {
{showTuner && tgStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />} {showTuner && tgStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
</button> </button>
)} )}
{/* Amplifier widget. With one amp the icon is a plain toggle; with
several it also carries a dropdown to choose which one the widget
shows (or All), since two amps stacked make a tall widget. */}
{ampSts.length > 0 && (
<div className="relative inline-flex">
<button
type="button"
onClick={() => { const v = !showAmpWidget; setShowAmpWidget(v); writeUiPref('opslog.showAmpWidget', v ? '1' : '0'); }}
title={showAmpWidget ? t('ampw.hideHint') : t('ampw.showHint')}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
ampSts.length > 1 && 'rounded-r-none border-r-0',
showAmpWidget ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Flame className="size-4" />
{showAmpWidget && ampSts.some((a: any) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flexAmp?.amp_available) && (
<span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />
)}
</button>
{ampSts.length > 1 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" title={t('ampw.pick')}
className={cn('inline-flex items-center justify-center h-7 w-4 rounded-md rounded-l-none border transition-colors',
showAmpWidget ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted')}>
<ChevronDown className="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-44">
{[{ id: 'all', name: t('ampw.all') }, ...ampSts.map((a: any) => ({ id: a.id, name: a.name || a.id }))].map((o) => (
<DropdownMenuItem key={o.id}
onSelect={() => {
setAmpWidgetSel(o.id);
writeUiPref('opslog.ampSel.widget', o.id);
// Picking an amp means "show me this one" — opening the
// widget too saves a second click on a hidden one.
if (!showAmpWidget) { setShowAmpWidget(true); writeUiPref('opslog.showAmpWidget', '1'); }
}}>
<span className="flex items-center gap-2 min-w-0">
{ampWidgetSel === o.id
? <Check className="size-3.5 shrink-0 text-primary" />
: <span className="size-3.5 shrink-0" />}
<span className="truncate">{o.name}</span>
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
{scpEnabled && ( {scpEnabled && (
<button <button
type="button" type="button"
@@ -5365,7 +5430,7 @@ export default function App() {
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the {/* Reserved free space to the right. The WinKeyer CW keyer and/or the
Digital Voice Keyer take this slot when enabled (Log4OM-style); Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */} otherwise it shows the QRZ profile photo. */}
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showScp && scpEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && ( {!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g. // relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
// the DVK with Auto CQ) can't grow the row — the row height stays set by // the DVK with Auto CQ) can't grow the row — the row height stays set by
// the entry strip and each widget fills that height, scrolling inside. // the entry strip and each widget fills that height, scrolling inside.
@@ -5452,6 +5517,19 @@ export default function App() {
/> />
</div> </div>
)} )}
{showAmpWidget && ampSts.length > 0 && (
// One column per amplifier shown, so two amps stand side by side
// rather than making the widget twice as tall as the dock row.
<div className="shrink-0 min-h-0"
style={{ width: `${Math.min(ampWidgetSel === 'all' ? ampSts.length : 1, 3) * 250 + 20}px` }}>
<AmpWidget
amps={ampSts}
flex={flexAmp}
sel={ampWidgetSel}
onClose={() => { setShowAmpWidget(false); writeUiPref('opslog.showAmpWidget', '0'); }}
/>
</div>
)}
{showTuner && tgEnabled && ( {showTuner && tgEnabled && (
<div className="w-[230px] shrink-0 min-h-0"> <div className="w-[230px] shrink-0 min-h-0">
<TunerGeniusPanel <TunerGeniusPanel
+13 -5
View File
@@ -51,11 +51,14 @@ type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgx
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) { export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card). // Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
// One second: the same window as the docked widget, so the two never show
// different figures for the same amplifier, and neither trails the end of a
// transmission long enough to look like lag.
const peak = useRef<Record<string, { v: number; t: number }>>({}); const peak = useRef<Record<string, { v: number; t: number }>>({});
const peakHold = (key: string, val: number) => { const peakHold = (key: string, val: number) => {
const now = Date.now(); const now = Date.now();
const p = peak.current[key]; const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; } if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
return p.v; return p.v;
}; };
@@ -73,9 +76,12 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}> spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{spe.operate ? 'OPERATE' : 'STANDBY'} {spe.operate ? 'OPERATE' : 'STANDBY'}
</button> </button>
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
is off (no status = not "connected"), and serial only. */}
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70"> <div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!spe.connected} <button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
onClick={() => AmpPower(amp.id, true).catch(() => {})} onClick={() => AmpPower(amp.id, true).catch(() => {})}
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button> className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
<button type="button" disabled={!spe.connected} <button type="button" disabled={!spe.connected}
onClick={() => AmpPower(amp.id, false).catch(() => {})} onClick={() => AmpPower(amp.id, false).catch(() => {})}
@@ -177,9 +183,6 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}> operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{operate ? 'OPERATE' : 'STANDBY'} {operate ? 'OPERATE' : 'STANDBY'}
</button> </button>
<span className="text-xs text-muted-foreground">
{operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
</span>
{(pg.host || pg.connected) && ( {(pg.host || pg.connected) && (
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}> <label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} /> <span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
@@ -208,6 +211,11 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP') const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim())); && !/^(RL|DRV)$/i.test((m.name || '').trim()));
if (amps.length === 0) return null; if (amps.length === 0) return null;
// Power comes from the radio's meter stream and nothing else. The
// amplifier also reports a "peakfwd", and using it was a mistake twice
// over: it is a latched maximum that is never reset, and it survives in
// the last-known status after the amp disconnects — so it claimed
// 1350 W from an old transmission while the radio was putting out 10.
return ( return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
{amps.map((m) => { {amps.map((m) => {
+281
View File
@@ -0,0 +1,281 @@
import { useRef } from 'react';
import { Flame, X, Power } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { MeterBar } from '@/components/MeterBar';
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
// AmpWidget — the docked amplifier widget, the compact counterpart of AmpCard
// (which is the full-size card shown in the FlexRadio panel and Station
// Control). It covers all three families, since an operator running an SPE and
// a PowerGenius wants one widget, not two:
// • SPE Expert — OPERATE, power ON/OFF, L/M/H, live power / SWR / temp
// • ACOM — OPERATE, power ON/OFF, live power / SWR / temp
// • PowerGenius — OPERATE and meters ride on the FlexRadio, fan mode on GSCP
// With several amplifiers configured the caller passes a selection ("all" or an
// amp id) chosen from the toolbar icon's dropdown.
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
// Full-scale watts for the output bar, from the model string.
function maxW(model?: string, fallback = 1300): number {
const m = (model || '').toUpperCase();
if (m.includes('20K') || m.includes('2K')) return 2000;
if (m.includes('15K')) return 1500;
if (m.includes('13K')) return 1300;
return fallback;
}
function swrColour(swr?: number): string {
if (!swr || swr <= 0) return 'text-muted-foreground';
if (swr <= 1.5) return 'text-success';
if (swr <= 2.0) return 'text-warning';
return 'text-danger';
}
// PowerMeter — the output-power LED bar that closes each column, straight under
// the controls (no filler above it: pushed to the bottom it left a band of dead
// space in the middle of the column). Uses the app-wide MeterBar, compact size,
// so it reads exactly like the meters on the rig panels.
function PowerMeter({ label, watts, maxWatts }: { label: string; watts: number; maxWatts: number }) {
return (
<MeterBar compact label={label} value={watts} unit="W" lo={0} hi={maxWatts}
display={`${Math.round(watts)} W`}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
);
}
// usePeakHold keeps the highest reading seen for a short window, so a reading
// sampled between two syllables doesn't collapse the meter. `live` is the key
// part: pass false the moment the carrier drops and the held value is thrown
// away at once — holding it there made releasing the PTT look like a five
// second lag, which read as the whole app being slow.
function usePeakHold(windowMs = 1000) {
const peak = useRef<Record<string, { v: number; t: number }>>({});
return (key: string, val: number, live = true) => {
if (!live) { delete peak.current[key]; return val; }
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
return p.v;
};
}
// Stat — one small labelled figure (power, SWR, temperature).
function Stat({ label, value, className }: { label: string; value: string; className?: string }) {
return (
<div className="rounded-lg border border-border bg-card/70 px-1.5 py-1 text-center">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground leading-tight">{label}</div>
<div className={cn('text-sm font-bold font-mono leading-tight', className ?? 'text-foreground/80')}>{value}</div>
</div>
);
}
// OperateButton — the STANDBY/OPERATE toggle, identical in behaviour across the
// three families (only where the state comes from differs).
function OperateButton({ operate, disabled, onClick, t }: {
operate: boolean; disabled: boolean; onClick: () => void; t: (k: string, v?: any) => string;
}) {
return (
<button type="button" disabled={disabled} onClick={onClick}
className={cn('w-full inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-[0.98] disabled:opacity-40',
operate
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_9px_rgba(245,158,11,0.45)]'
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground')}>
<Power className="size-3.5" />
{operate ? t('ampw.operate') : t('ampw.standby')}
</button>
);
}
// AmpBlock — one amplifier inside the widget.
function AmpBlock({ amp, flex, showName, t }: {
amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string;
}) {
const spe = amp.spe, acom = amp.acom;
const hold = usePeakHold();
if (spe || acom) {
const s = spe || acom;
// The amp reports zero watts on receive, so its TX flag clears the meter as
// soon as the operator lets go — and the radio's own flag, when we have one,
// gets there first (the amp is polled on its own slower cycle).
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false;
const w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing);
const swr = Number(spe ? s.swr_ant : s.swr) || 0;
const hi = spe ? maxW(s.model) : (Number(s.max_w) || 800);
// Power ON drives the remote-on control lines, so it stays available while
// the amplifier is off and reporting nothing — but only over a serial link.
const canPowerOn = spe ? (s.connected || s.transport === 'serial') : (s.port_open && s.transport === 'serial');
return (
<div className="h-full flex flex-col gap-1.5">
{showName && (
<div className="flex items-center gap-1.5">
<span className={cn('size-1.5 rounded-full shrink-0', s.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : 'ACOM')}</span>
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
</div>
)}
<OperateButton operate={!!s.operate} disabled={!s.connected} t={t}
onClick={() => AmpOperate(amp.id, !s.operate).catch(() => {})} />
<div className="flex gap-1.5">
<button type="button" disabled={!canPowerOn}
onClick={() => AmpPower(amp.id, true).catch(() => {})}
className="flex-1 rounded-lg border border-success/60 bg-card py-1 text-[11px] font-bold text-success hover:bg-success/15 disabled:opacity-30">
{t('ampw.on')}
</button>
<button type="button" disabled={!s.connected}
onClick={() => AmpPower(amp.id, false).catch(() => {})}
className="flex-1 rounded-lg border border-danger/60 bg-card py-1 text-[11px] font-bold text-danger hover:bg-danger/15 disabled:opacity-30">
{t('ampw.off')}
</button>
</div>
{/* Output level — SPE only; an ACOM has no L/M/H over CAT. */}
{spe && (
<div className="grid grid-cols-3 gap-1">
{(['L', 'M', 'H'] as const).map((lvl) => {
const active = (s.power_level || '').trim().toUpperCase() === lvl;
return (
<button key={lvl} type="button" disabled={!s.connected}
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
className={cn('rounded-md border py-0.5 text-[10px] font-bold transition-colors disabled:opacity-30',
active ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{t(`ampw.lvl${lvl}`)}
</button>
);
})}
</div>
)}
{s.connected ? (
<div className="grid grid-cols-3 gap-1">
<Stat label={t('ampw.pwr')} value={`${Math.round(w)}`} />
<Stat label={t('ampw.swr')} value={swr > 0 ? swr.toFixed(1) : '—'} className={swrColour(swr)} />
<Stat label={t('ampw.temp')} value={`${Number(s.temp_c) || 0}°`} />
</div>
) : (
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
)}
{(s.warnings || s.alarms || s.err_text) && (
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
{s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
</div>
)}
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
</div>
);
}
// PowerGenius XL: OPERATE and the meters come from the radio when the Flex
// reports the amp; the fan mode rides on our own GSCP link.
const pg = amp.pgxl || {};
const viaFlex = !!flex?.amp_available;
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
const connected = !!pg.connected || viaFlex;
const fault = flex?.amp_fault;
const meters = ((flex?.meters as any[]) || []).filter((m) => (m.src || '').toUpperCase().includes('AMP'));
const meter = (re: RegExp) => meters.find((m) => re.test((m.name || '').trim()));
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
const fwd = meter(/^fwd|pwr/i);
const flexW = fwd ? (/dbm/i.test(fwd.unit || '') ? dbmToW(fwd.value) : fwd.value) : 0;
// Whether there is power is the RADIO's transmit flag: it flips the instant
// the PTT does, where the amplifier's own status poll answers a second and a
// half late and holds TRANSMIT past the carrier.
//
// How MUCH is the live reading — the radio's meter stream, or the amp's own
// forward figure when there is no Flex. NOT the amp's "peakfwd": that is a
// latched maximum which is never reset AND survives in the last-known status
// after the amp disconnects, so it reported 1350 W from an old transmission
// while the radio was putting out 10 W into a disconnected amplifier.
const txing = typeof flex?.transmitting === 'boolean'
? flex.transmitting
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
const liveW = flexW > 0 ? flexW : (pg.connected ? Number(pg.fwd_w) || 0 : 0);
const fwdW = hold('fwd', txing ? liveW : 0, txing);
const id = meter(/^ID$|current/i);
// Live drain current, for the same reason: peak_id latches like peak_w.
const idA = id ? Number(id.value) : (txing ? Number(pg.id) || undefined : 0);
const temp = meter(/temp/i);
const tempC = pg.temperature > 0 ? pg.temperature : (temp ? temp.value : undefined);
return (
<div className="h-full flex flex-col gap-1.5">
{showName && (
<div className="flex items-center gap-1.5">
<span className={cn('size-1.5 rounded-full shrink-0', connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || 'PGXL'}</span>
</div>
)}
<OperateButton operate={operate} disabled={!connected} t={t}
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})} />
{connected ? (
<div className="grid grid-cols-3 gap-1">
<Stat label={t('ampw.pwr')} value={`${Math.round(fwdW)}`} />
<Stat label={t('ampw.id')} value={idA != null ? idA.toFixed(1) : '—'} />
<Stat label={t('ampw.temp')} value={tempC != null ? `${Math.round(tempC)}°` : '—'} />
</div>
) : (
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
)}
{(pg.host || pg.connected) && (
<select
disabled={!pg.connected}
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
title={t('ampw.fan')}
className="w-full h-7 rounded-md border border-border bg-card px-1.5 text-[10px] font-semibold outline-none focus:border-warning disabled:opacity-40">
<option value="STANDARD">{t('flxp.fanStandard')}</option>
<option value="CONTEST">{t('flxp.fanContest')}</option>
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
</select>
)}
{fault && fault !== 'NONE' && (
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
{t('flxp.fault')}: {fault}
</div>
)}
<PowerMeter label={t('flxp.outputPower')} watts={connected ? fwdW : 0} maxWatts={2000} />
</div>
);
}
export function AmpWidget({ amps, flex, sel, onClose }: {
amps: Amp[];
flex: any;
sel: string; // "all" or an amp id
onClose: () => void;
}) {
const { t } = useI18n();
// An id that no longer matches (the amp was removed in Settings) falls back to
// showing everything rather than an empty widget.
const shown = sel === 'all' ? amps : (amps.filter((a) => a.id === sel).length ? amps.filter((a) => a.id === sel) : amps);
const anyOnline = shown.some((a) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flex?.amp_available);
const title = shown.length === 1 ? (shown[0].name || t('ampw.title')) : t('ampw.title');
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
<Flame className={cn('size-4', anyOnline ? 'text-warning drop-shadow-[0_0_3px_rgba(245,158,11,0.55)]' : 'text-muted-foreground')} />
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80 truncate">{title}</span>
<span className="flex-1" />
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('ampw.close')}>
<X className="size-3.5" />
</button>
</div>
{/* Several amplifiers sit SIDE BY SIDE, one column each: stacked, two amps
made a widget taller than the dock row and both ended up half hidden. */}
<div className="flex-1 min-h-0 overflow-y-auto p-2.5">
{shown.length === 0 ? (
<div className="text-[11px] text-center text-muted-foreground italic py-2">{t('ampw.none')}</div>
) : (
<div className="grid gap-2.5 h-full" style={{ gridTemplateColumns: `repeat(${shown.length}, minmax(0, 1fr))` }}>
{shown.map((a, i) => (
<div key={a.id} className={cn(i > 0 && 'pl-2.5 border-l border-border/50')}>
<AmpBlock amp={a} flex={flex} showName={shown.length > 1} t={t} />
</div>
))}
</div>
)}
</div>
</div>
);
}
+17 -17
View File
@@ -86,13 +86,6 @@ function Slider({ value, onChange, disabled, accent = '#16a34a', step = 1, max =
if (!el) return; if (!el) return;
const onWheel = (e: WheelEvent) => { const onWheel = (e: WheelEvent) => {
if (disRef.current) return; if (disRef.current) return;
// The wheel only acts on a slider the operator has DELIBERATELY taken
// hold of — one that has focus. Hovering was enough before, so scrolling
// the panel with the pointer anywhere over these controls silently moved
// whatever sat under it: an IC-7800 owner watched his transmit power
// change on its own. Without focus the event is left alone and the panel
// scrolls, which is what the gesture was for.
if (document.activeElement !== el) return;
e.preventDefault(); e.preventDefault();
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current; const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
const nv = Math.max(0, Math.min(maxRef.current, valRef.current + d)); const nv = Math.max(0, Math.min(maxRef.current, valRef.current + d));
@@ -330,13 +323,15 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [st.cw_speed]); }, [st.cw_speed]);
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters // Peak-hold: keep the highest reading for a second so the jittery VITA-49
// read steadily instead of jumping every poll. // meters read steadily instead of jumping every poll. One second, not two —
// longer and the meters visibly trail the end of a transmission, which reads
// as the app lagging rather than as a peak being held.
const peak = useRef<Record<string, { v: number; t: number }>>({}); const peak = useRef<Record<string, { v: number; t: number }>>({});
const peakHold = (key: string, val: number) => { const peakHold = (key: string, val: number, windowMs = 1000) => {
const now = Date.now(); const now = Date.now();
const p = peak.current[key]; const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; } if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
return p.v; return p.v;
}; };
@@ -362,7 +357,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
}, []); }, []);
// PowerGenius XL direct connection (fan mode), independent of the Flex link. // PowerGenius XL direct connection (fan mode), independent of the Flex link.
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string }>({ connected: false }); const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string; state?: string; peak_w?: number }>({ connected: false });
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} }; const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
@@ -985,10 +980,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}> spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{spe.operate ? 'OPERATE' : 'STANDBY'} {spe.operate ? 'OPERATE' : 'STANDBY'}
</button> </button>
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */} {/* Power ON pulses RTS then DTR — it must stay clickable while the amp
is off (no status = not "connected"), and serial only. */}
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70"> <div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!spe.connected} <button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
onClick={() => AmpPower(selAmp.id, true).catch(() => {})} onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button> className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
<button type="button" disabled={!spe.connected} <button type="button" disabled={!spe.connected}
onClick={() => AmpPower(selAmp.id, false).catch(() => {})} onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
@@ -1090,9 +1087,6 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
st.amp_operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}> st.amp_operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{st.amp_operate ? 'OPERATE' : 'STANDBY'} {st.amp_operate ? 'OPERATE' : 'STANDBY'}
</button> </button>
<span className="text-xs text-muted-foreground">
{st.amp_operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
</span>
{/* Fan mode — shown when the PowerGenius is configured (Settings → {/* Fan mode — shown when the PowerGenius is configured (Settings →
PowerGenius). The dot shows the direct-connection state; the PowerGenius). The dot shows the direct-connection state; the
selector is disabled until connected (hover it for the error). */} selector is disabled until connected (hover it for the error). */}
@@ -1125,6 +1119,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP') const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim())); && !/^(RL|DRV)$/i.test((m.name || '').trim()));
if (off || amp.length === 0) return null; if (off || amp.length === 0) return null;
// Power comes from the radio's meter stream and nothing else. The
// amplifier also reports a "peakfwd", and using it was a mistake
// twice over: it is a latched maximum that is never reset, and it
// survives in the last-known status after the amp disconnects — so
// it claimed 1350 W from an old transmission while the radio was
// putting out 10.
return ( return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
{amp.map((m) => { {amp.map((m) => {
-7
View File
@@ -125,13 +125,6 @@ function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
if (!el) return; if (!el) return;
const onWheel = (e: WheelEvent) => { const onWheel = (e: WheelEvent) => {
if (disRef.current) return; if (disRef.current) return;
// The wheel only acts on a slider the operator has DELIBERATELY taken
// hold of — one that has focus. Hovering was enough before, so scrolling
// the panel with the pointer anywhere over these controls silently moved
// whatever sat under it: an IC-7800 owner watched his transmit power
// change on its own. Without focus the event is left alone and the panel
// scrolls, which is what the gesture was for.
if (document.activeElement !== el) return;
e.preventDefault(); e.preventDefault();
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current; const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
const nv = Math.max(0, Math.min(100, valRef.current + d)); const nv = Math.max(0, Math.min(100, valRef.current + d));
+49 -20
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react'; import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, 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';
@@ -26,6 +26,13 @@ type Device = { id: string; type: string; name: string; host: string; user?: str
type Relay = { number: number; label: string; on: boolean }; type Relay = { number: number; label: string; on: boolean };
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] }; type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
// Dashboard geometry: a grid of EQUAL columns whose cards also share a common
// HEIGHT per row — every card stretches to the tallest one beside it. Masonry
// packed tighter but left the row edges ragged, which read as untidy on a wide
// screen; aligned rows are what an operator expects from a dashboard.
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
const GRID_GAP = 16; // px between cards, both axes
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 }; const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 };
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' }; const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
@@ -269,6 +276,23 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
// Max columns per row (persisted). "Auto" fills the window; a number caps the // Max columns per row (persisted). "Auto" fills the window; a number caps the
// row so cards wrap onto further lines even when there's horizontal room. // row so cards wrap onto further lines even when there's horizontal room.
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto'); const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
// How many columns the dashboard shows. "Auto" fits as many CARD_MIN-wide
// cards as the window allows; an explicit count is honoured as asked, even
// past that width — the operator can see his own screen.
const gridRef = useRef<HTMLDivElement | null>(null);
const [colCount, setColCount] = useState(1);
useLayoutEffect(() => {
if (cols !== 'auto') { setColCount(Number(cols) || 1); return; }
const el = gridRef.current;
if (!el) return;
const measure = () => setColCount(Math.max(1, Math.floor((el.clientWidth + GRID_GAP) / (CARD_MIN + GRID_GAP))));
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [cols]);
// Amplifiers (Settings → Amplifier): shown here so operators without a // Amplifiers (Settings → Amplifier): shown here so operators without a
// FlexRadio panel still get the controls. EVERY configured amp gets its own // FlexRadio panel still get the controls. EVERY configured amp gets its own
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the // card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
@@ -415,10 +439,10 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
); );
}; };
// `wide` cards span the whole dashboard instead of sitting in one masonry // `wide` cards take two grid columns instead of one. The amplifier and tuner
// column. The amplifier and tuner carry meter rows and a channel selector that // carry meter rows and a channel selector that are unreadable squeezed into a
// are unreadable squeezed into a ~430px column — they are the same cards the // single ~430px column — they are the same cards the FlexRadio panel shows
// FlexRadio panel shows full-width, and they need that room here too. // full-width, and they need that room here too.
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = []; const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
if (rot.enabled) { if (rot.enabled) {
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> }); widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
@@ -446,7 +470,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a {/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
card can sit on a further line even with horizontal room to spare. */} card can sit on a further line even with horizontal room to spare. */}
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]"> <div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
{(['auto', '1', '2', '3', '4'] as const).map((c) => ( {(['auto', '1', '2', '3', '4', '5', '6'] as const).map((c) => (
<button key={c} type="button" <button key={c} type="button"
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }} 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')}> className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
@@ -466,22 +490,26 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div> </div>
)} )}
{/* Masonry dashboard: fixed-width cards flow into balanced CSS columns so {/* Dashboard grid: equal columns, and every card stretches to the height of
they pack tightly by height (no ragged gaps under short cards). "Auto" the tallest one on its row, so the rows line up instead of ending
fits as many ~430px columns as the window allows; a fixed count caps the ragged. One column each, two for the amplifier/tuner whose meter rows
container width to that many columns. Each card has a grip handle (left need the room. Each card has a grip handle (left rail) as the drag
rail) as the drag initiator (the body is full of buttons). */} initiator, since the body is full of buttons. */}
<div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance', <div ref={gridRef} style={{
...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}> display: 'grid',
gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
gap: `${GRID_GAP}px`,
}}>
{ordered.map((w) => ( {ordered.map((w) => (
// column-span:all lifts a wide card out of the columns and across the <div key={w.id}
// full container, while keeping it in document order — so drag-reorder // A wide card takes two columns — but never more than there are, or
// still works between wide and normal cards. Capped so it stays a card // the grid would grow a phantom column on a narrow window.
// and not a banner on an ultra-wide window. style={{ gridColumn: w.wide ? `span ${Math.min(2, colCount)}` : undefined }}
<div key={w.id} className="flex items-stretch break-inside-avoid mb-4"
style={w.wide ? { columnSpan: 'all', maxWidth: '900px' } : undefined}
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }} onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}> onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
{/* h-full down the chain is what makes the card fill the row height
rather than sit at its natural size in a taller cell. */}
<div className="flex items-stretch h-full">
<div draggable <div draggable
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }} onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
onDragEnd={() => { dragId.current = null; }} onDragEnd={() => { dragId.current = null; }}
@@ -490,7 +518,8 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
dragId.current === w.id && 'opacity-60')}> dragId.current === w.id && 'opacity-60')}>
<GripVertical className="size-4" /> <GripVertical className="size-4" />
</div> </div>
<div className="flex-1 min-w-0">{w.node}</div> <div className="flex-1 min-w-0 [&>*]:h-full">{w.node}</div>
</div>
</div> </div>
))} ))}
</div> </div>
+8 -4
View File
@@ -76,13 +76,16 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
const peakHold = (key: string, val: number) => { const peakHold = (key: string, val: number) => {
const now = Date.now(); const now = Date.now();
const p = peak.current[key]; const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; } if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
return p.v; return p.v;
}; };
const connected = !!status.connected; const connected = !!status.connected;
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined; const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
const rawFwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0; // Prefer the device's own peak detector over the sampled reading; the JS hold
// below still smooths what is left between polls.
const rawPeak = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
const rawFwdW = rawPeak >= 1 ? rawPeak : 0;
const fwdW = peakHold('fwd', rawFwdW); const fwdW = peakHold('fwd', rawFwdW);
// SWR needs RF to mean anything: sampled on receive the device reports a // SWR needs RF to mean anything: sampled on receive the device reports a
@@ -93,13 +96,14 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
// That expiry is the point: the first version of this held the last TX value // That expiry is the point: the first version of this held the last TX value
// with no timeout at all, so the meter sat frozen on the previous transmission // with no timeout at all, so the meter sat frozen on the previous transmission
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward. // indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
// Same 2 s window as the power peak above, so the two meters clear together. // Same one-second window as the power peak above, so the two meters clear
// together.
const swrHold = useRef<{ v: number; t: number } | null>(null); const swrHold = useRef<{ v: number; t: number } | null>(null);
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() }; if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
const heldSwr = swrHold.current; const heldSwr = swrHold.current;
const vswr = rawFwdW >= 1 const vswr = rawFwdW >= 1
? rawVswr ? rawVswr
: heldSwr && Date.now() - heldSwr.t < 2000 : heldSwr && Date.now() - heldSwr.t < 1000
? heldSwr.v ? heldSwr.v
: undefined; : undefined;
const active = status.active ?? 1; const active = status.active ?? 1;
+6 -2
View File
@@ -8,7 +8,7 @@ export type TGChannel = {
}; };
export type TGStatus = { export type TGStatus = {
connected: boolean; host?: string; last_error?: string; connected: boolean; host?: string; last_error?: string;
fwd_dbm?: number; fwd_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number; fwd_dbm?: number; fwd_w?: number; peak_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number; operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
antenna?: number; three_way?: boolean; message?: string; antenna?: number; three_way?: boolean; message?: string;
a?: TGChannel; b?: TGChannel; a?: TGChannel; b?: TGChannel;
@@ -69,6 +69,10 @@ export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActiva
const { t } = useI18n(); const { t } = useI18n();
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined; const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
const active = status.active ?? 1; const active = status.active ?? 1;
// The device's own peak, not the instantaneous reading: a 400 ms poll lands
// between syllables as often as on a peak, so the plain figure showed a few
// hundred watts on a kilowatt transmission.
const fwdW = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
return ( return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden"> <div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
@@ -103,7 +107,7 @@ export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActiva
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center"> <div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div> <div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
<div className="text-lg font-bold font-mono leading-tight text-foreground/80"> <div className="text-lg font-bold font-mono leading-tight text-foreground/80">
{status.fwd_w && status.fwd_w >= 1 ? `${Math.round(status.fwd_w)} W` : '—'} {fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
</div> </div>
</div> </div>
</div> </div>
-7
View File
@@ -149,13 +149,6 @@ function Slider({ value, onChange, disabled, accent = 'var(--primary)', min = 0,
if (!el) return; if (!el) return;
const onWheel = (e: WheelEvent) => { const onWheel = (e: WheelEvent) => {
if (disRef.current) return; if (disRef.current) return;
// The wheel only acts on a slider the operator has DELIBERATELY taken
// hold of — one that has focus. Hovering was enough before, so scrolling
// the panel with the pointer anywhere over these controls silently moved
// whatever sat under it: an IC-7800 owner watched his transmit power
// change on its own. Without focus the event is left alone and the panel
// scrolls, which is what the gesture was for.
if (document.activeElement !== el) return;
e.preventDefault(); e.preventDefault();
const nv = Math.max(minRef.current, Math.min(maxRef.current, valRef.current + (e.deltaY < 0 ? 1 : -1))); const nv = Math.max(minRef.current, Math.min(maxRef.current, valRef.current + (e.deltaY < 0 ? 1 : -1)));
if (nv !== valRef.current) cbRef.current(nv); if (nv !== valRef.current) cbRef.current(nv);
+12
View File
@@ -352,6 +352,12 @@ const en: Dict = {
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}', 'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Repeat a CQ-labelled message on a timer until you stop it or play another slot', 'dvkp.gap': 'Gap', 'dvkp.notPhone': 'The voice keyer only transmits on a phone mode (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message', 'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Repeat a CQ-labelled message on a timer until you stop it or play another slot', 'dvkp.gap': 'Gap', 'dvkp.notPhone': 'The voice keyer only transmits on a phone mode (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band', 'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band',
'ampw.title': 'Amplifier', 'ampw.all': 'All amplifiers', 'ampw.pick': 'Which amplifier the widget shows',
'ampw.showHint': 'Amplifier · click to show', 'ampw.hideHint': 'Amplifier — shown · click to hide',
'ampw.close': 'Close', 'ampw.offline': 'offline', 'ampw.none': 'No amplifier configured',
'ampw.operate': 'Operate', 'ampw.standby': 'Standby', 'ampw.on': 'ON', 'ampw.off': 'OFF',
'ampw.lvlL': 'Low', 'ampw.lvlM': 'Mid', 'ampw.lvlH': 'High',
'ampw.pwr': 'Watts', 'ampw.swr': 'SWR', 'ampw.temp': 'Temp', 'ampw.id': 'Id (A)', 'ampw.fan': 'Fan mode',
'tgp.online': 'online', 'tgp.offline': 'offline', 'tgp.close': 'Close', 'tgp.connecting': 'Connecting…', 'tgp.swr': 'SWR', 'tgp.power': 'Fwd power', 'tgp.tune': 'Tune', 'tgp.tuning': 'Tuning…', 'tgp.tuneHint': 'Start an automatic tuning cycle on the active channel — key the rig into a carrier so the tuner can measure SWR', 'tgp.bypass': 'Bypass', 'tgp.bypassHint': 'Toggle global bypass — route the antenna straight through, tuner out of line', 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Toggle Operate / Standby', 'tgp.online': 'online', 'tgp.offline': 'offline', 'tgp.close': 'Close', 'tgp.connecting': 'Connecting…', 'tgp.swr': 'SWR', 'tgp.power': 'Fwd power', 'tgp.tune': 'Tune', 'tgp.tuning': 'Tuning…', 'tgp.tuneHint': 'Start an automatic tuning cycle on the active channel — key the rig into a carrier so the tuner can measure SWR', 'tgp.bypass': 'Bypass', 'tgp.bypassHint': 'Toggle global bypass — route the antenna straight through, tuner out of line', 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Toggle Operate / Standby',
'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Channel {letter} — active', 'tgp.chSelect': 'Make channel {letter} active', 'tgp.chActiveTag': 'active', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypassed', 'tgp.inLine': 'In line', 'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Channel {letter} — active', 'tgp.chSelect': 'Make channel {letter} active', 'tgp.chActiveTag': 'active', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypassed', 'tgp.inLine': 'In line',
'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.',
@@ -747,6 +753,12 @@ const fr: Dict = {
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}', 'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Répète un message libellé CQ à intervalle régulier jusqu\'à l\'arrêt ou la lecture d\'un autre slot', 'dvkp.gap': 'Intervalle', 'dvkp.notPhone': 'Le manipulateur vocal n\'émet qu\'en mode phonie (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message', 'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Répète un message libellé CQ à intervalle régulier jusqu\'à l\'arrêt ou la lecture d\'un autre slot', 'dvkp.gap': 'Intervalle', 'dvkp.notPhone': 'Le manipulateur vocal n\'émet qu\'en mode phonie (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour nafficher que la bande courante', 'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour nafficher que la bande courante',
'ampw.title': 'Amplificateur', 'ampw.all': 'Tous les amplis', 'ampw.pick': 'Ampli affiché par le widget',
'ampw.showHint': 'Amplificateur · cliquer pour afficher', 'ampw.hideHint': 'Amplificateur — affiché · cliquer pour masquer',
'ampw.close': 'Fermer', 'ampw.offline': 'hors ligne', 'ampw.none': 'Aucun amplificateur configuré',
'ampw.operate': 'Operate', 'ampw.standby': 'Standby', 'ampw.on': 'ON', 'ampw.off': 'OFF',
'ampw.lvlL': 'Low', 'ampw.lvlM': 'Mid', 'ampw.lvlH': 'High',
'ampw.pwr': 'Watts', 'ampw.swr': 'ROS', 'ampw.temp': 'Temp', 'ampw.id': 'Id (A)', 'ampw.fan': 'Mode ventil.',
'tgp.online': 'en ligne', 'tgp.offline': 'hors ligne', 'tgp.close': 'Fermer', 'tgp.connecting': 'Connexion…', 'tgp.swr': 'ROS', 'tgp.power': 'Puiss. directe', 'tgp.tune': 'Accord', 'tgp.tuning': 'Accord…', 'tgp.tuneHint': "Lancer un cycle d'accord automatique sur le canal actif — passe la radio en porteuse pour que le coupleur mesure le ROS", 'tgp.bypass': 'Bypass', 'tgp.bypassHint': "Basculer le bypass global — antenne en direct, coupleur hors ligne", 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Basculer Operate / Standby', 'tgp.online': 'en ligne', 'tgp.offline': 'hors ligne', 'tgp.close': 'Fermer', 'tgp.connecting': 'Connexion…', 'tgp.swr': 'ROS', 'tgp.power': 'Puiss. directe', 'tgp.tune': 'Accord', 'tgp.tuning': 'Accord…', 'tgp.tuneHint': "Lancer un cycle d'accord automatique sur le canal actif — passe la radio en porteuse pour que le coupleur mesure le ROS", 'tgp.bypass': 'Bypass', 'tgp.bypassHint': "Basculer le bypass global — antenne en direct, coupleur hors ligne", 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Basculer Operate / Standby',
'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Canal {letter} — actif', 'tgp.chSelect': 'Activer le canal {letter}', 'tgp.chActiveTag': 'actif', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypass', 'tgp.inLine': 'En ligne', 'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Canal {letter} — actif', 'tgp.chSelect': 'Activer le canal {letter}', 'tgp.chActiveTag': 'actif', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypass', 'tgp.inLine': 'En ligne',
'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.",
+2
View File
@@ -17,6 +17,8 @@ const PORTABLE_KEYS = [
'opslog.autofocusWB', // auto-focus Worked-before 'opslog.autofocusWB', // auto-focus Worked-before
'hamlog.filterPresets', // Filter Builder saved presets 'hamlog.filterPresets', // Filter Builder saved presets
'opslog.showRotor', // rotor compass shown next to the keyers 'opslog.showRotor', // rotor compass shown next to the keyers
'opslog.showAmpWidget', // amplifier widget shown next to the keyers
'opslog.ampSel.widget', // which amplifier that widget shows ("all" or an amp id)
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map 'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time) 'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header 'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.22.8'; export const APP_VERSION = '0.22.9';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+14
View File
@@ -3501,6 +3501,11 @@ export namespace powergenius {
fan_mode?: string; fan_mode?: string;
temperature: number; temperature: number;
operate: boolean; operate: boolean;
fwd_w: number;
peak_w: number;
vswr: number;
id: number;
peak_id: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Status(source); return new Status(source);
@@ -3515,6 +3520,11 @@ export namespace powergenius {
this.fan_mode = source["fan_mode"]; this.fan_mode = source["fan_mode"];
this.temperature = source["temperature"]; this.temperature = source["temperature"];
this.operate = source["operate"]; this.operate = source["operate"];
this.fwd_w = source["fwd_w"];
this.peak_w = source["peak_w"];
this.vswr = source["vswr"];
this.id = source["id"];
this.peak_id = source["peak_id"];
} }
} }
@@ -4560,6 +4570,7 @@ export namespace spe {
export class Status { export class Status {
connected: boolean; connected: boolean;
transport: string;
last_error?: string; last_error?: string;
model?: string; model?: string;
operate: boolean; operate: boolean;
@@ -4583,6 +4594,7 @@ export namespace spe {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.connected = source["connected"]; this.connected = source["connected"];
this.transport = source["transport"];
this.last_error = source["last_error"]; this.last_error = source["last_error"];
this.model = source["model"]; this.model = source["model"];
this.operate = source["operate"]; this.operate = source["operate"];
@@ -4637,6 +4649,7 @@ export namespace tunergenius {
last_error?: string; last_error?: string;
fwd_dbm: number; fwd_dbm: number;
fwd_w: number; fwd_w: number;
peak_w: number;
swr_db: number; swr_db: number;
vswr: number; vswr: number;
operate: boolean; operate: boolean;
@@ -4664,6 +4677,7 @@ export namespace tunergenius {
this.last_error = source["last_error"]; this.last_error = source["last_error"];
this.fwd_dbm = source["fwd_dbm"]; this.fwd_dbm = source["fwd_dbm"];
this.fwd_w = source["fwd_w"]; this.fwd_w = source["fwd_w"];
this.peak_w = source["peak_w"];
this.swr_db = source["swr_db"]; this.swr_db = source["swr_db"];
this.vswr = source["vswr"]; this.vswr = source["vswr"];
this.operate = source["operate"]; this.operate = source["operate"];
+7 -15
View File
@@ -314,7 +314,12 @@ func (f *Flex) send(cmd string) int {
debugLog.Printf("Flex: send %q failed: %v", cmd, err) debugLog.Printf("Flex: send %q failed: %v", cmd, err)
return 0 return 0
} }
// Meter subscriptions are pure bookkeeping and a Flex announces meters one
// status line at a time — tracing each "sub meter N" wrote dozens of lines
// at every connect.
if !strings.HasPrefix(cmd, "sub meter ") {
debugLog.Printf("Flex: → %s", cmd) debugLog.Printf("Flex: → %s", cmd)
}
return seq return seq
} }
@@ -773,17 +778,11 @@ func (f *Flex) handleStatus(payload string) {
f.meterMeta[num] = old f.meterMeta[num] = old
} }
f.mu.Unlock() f.mu.Unlock()
// One line for the whole batch, not one per meter: a Flex announces // Not logged: the radio announces meters ONE per status line, so even
// dozens at connect and that was a wall of text every time. // a batched line meant dozens of them at every connect.
var names []string
for _, id := range newIDs { for _, id := range newIDs {
mi := f.meterMeta[id]
names = append(names, nonEmpty(mi.name, strconv.Itoa(id)))
f.subscribeMeter(id) f.subscribeMeter(id)
} }
if len(names) > 0 {
debugLog.Printf("Flex: subscribed to %d meter(s): %s", len(names), strings.Join(names, " "))
}
} }
// Spot status: "spot <index> …". Track the index so we can clear the // Spot status: "spot <index> …". Track the index so we can clear the
// panadapter, and log it verbatim — a click on a panadapter spot pushes a // panadapter, and log it verbatim — a click on a panadapter spot pushes a
@@ -2261,13 +2260,6 @@ func (f *Flex) subscribeMeter(id int) {
f.send(fmt.Sprintf("sub meter %d", id)) f.send(fmt.Sprintf("sub meter %d", id))
} }
func nonEmpty(s, def string) string {
if s == "" {
return def
}
return s
}
func parseFloatDefault(s string, def float64) float64 { func parseFloatDefault(s string, def float64) float64 {
if v, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil { if v, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
return v return v
+55 -4
View File
@@ -114,6 +114,15 @@ type IcomSerial struct {
silentGrace time.Duration // current width of that tolerance (backs off, see ReadState) silentGrace time.Duration // current width of that tolerance (backs off, see ReadState)
dspLoaded bool // readDSP has run since the rig became responsive (loads all dspLoaded bool // readDSP has run since the rig became responsive (loads all
// the panel's set-once controls once the rig actually answers) // the panel's set-once controls once the rig actually answers)
// When the console last asked for the DSP snapshot. The meters and the
// front-panel rotation are polled ONLY while something is displaying them:
// they are half of OpsLog's CI-V traffic, and on a shared bus — a microHAM
// Router splitting one CI-V link into two virtual ports, OpsLog on one and
// WSJT-X/MSHV on the other — every frame we don't need is a frame that can
// collide with the other program's.
dspWantMu sync.Mutex
dspWantAt time.Time
lastSetFreq int64 // last frequency commanded (spot click: freq then mode) lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
lastSetFreqAt time.Time lastSetFreqAt time.Time
@@ -409,9 +418,18 @@ func (b *IcomSerial) ReadState() (RigState, error) {
} }
// Live meters + TX state for the Icom panel (the rig doesn't push these). // Live meters + TX state for the Icom panel (the rig doesn't push these).
// The meters are polled only while the console is actually on screen: they
// were three CI-V round trips per cycle, every cycle, whether or not anyone
// could see them. The previous readings are kept so the panel opens on the
// last known values rather than on zeros.
tx := b.readTX() tx := b.readTX()
sm, _ := b.readMeter(civ.SubMeterS) watched := b.panelWatched()
po, swr := 0, 0 b.dspMu.Lock()
sm, po, swr := b.dsp.SMeter, b.dsp.PowerMeter, b.dsp.SWRMeter
b.dspMu.Unlock()
if watched {
sm, _ = b.readMeter(civ.SubMeterS)
po, swr = 0, 0
if tx { if tx {
if v, ok := b.readMeter(civ.SubMeterPo); ok { if v, ok := b.readMeter(civ.SubMeterPo); ok {
po = v po = v
@@ -420,6 +438,7 @@ func (b *IcomSerial) ReadState() (RigState, error) {
swr = v swr = v
} }
} }
}
b.dspMu.Lock() b.dspMu.Lock()
b.dsp.Available = true b.dsp.Available = true
b.dsp.Model = b.model b.dsp.Model = b.model
@@ -434,12 +453,16 @@ func (b *IcomSerial) ReadState() (RigState, error) {
// snapshot so the panel's antenna, sliders, RIT, notch, etc. reflect the rig // snapshot so the panel's antenna, sliders, RIT, notch, etc. reflect the rig
// instead of sitting at their zero defaults. Runs once; ↻ Refresh re-reads on // instead of sitting at their zero defaults. Runs once; ↻ Refresh re-reads on
// demand, and a reconnect re-arms it (Connect clears dspLoaded). // demand, and a reconnect re-arms it (Connect clears dspLoaded).
// Both of these only make sense for the console — don't spend the bus on them
// while it is closed. The snapshot then loads the first time it is opened.
if watched {
if !b.dspLoaded { if !b.dspLoaded {
b.readDSP() b.readDSP()
b.dspLoaded = true b.dspLoaded = true
} else { } else {
b.refreshFrontPanel() b.refreshFrontPanel()
} }
}
return s, nil return s, nil
} }
@@ -532,12 +555,25 @@ func (b *IcomSerial) SetPower(on bool) error {
return fmt.Errorf("icom: not connected") return fmt.Errorf("icom: not connected")
} }
if on { if on {
buf := make([]byte, 0, 32) // The preamble has to last long enough IN TIME for the sleeping rig to
for i := 0; i < 25; i++ { // notice it, so its length scales with the baud rate — Icom's own table
// asks for 7 bytes at 4800 baud and 150 at 115200, which is a fixed
// ~25 ms of carrier. A flat 25 bytes (what this sent before) is right at
// 19200 and far too short above it, so a rig configured for 115200 simply
// ignored the command.
n := b.baud / 768
if n < 7 {
n = 7
} else if n > 150 {
n = 150
}
buf := make([]byte, 0, n+8)
for i := 0; i < n; i++ {
buf = append(buf, 0xFE) buf = append(buf, 0xFE)
} }
buf = append(buf, 0xFE, 0xFE, b.rigAddr, civ.AddrController, civ.CmdPower, 0x01, 0xFD) buf = append(buf, 0xFE, 0xFE, b.rigAddr, civ.AddrController, civ.CmdPower, 0x01, 0xFD)
_, err := b.port.Write(buf) _, err := b.port.Write(buf)
applog.Printf("icom: power ON — %d-byte wake preamble at %d baud, addr 0x%02X (err=%v)", n, b.baud, b.rigAddr, err)
return err return err
} }
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, civ.CmdPower, 0x00)) _, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, civ.CmdPower, 0x00))
@@ -1289,11 +1325,26 @@ func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
// ── IcomController: receive-DSP controls for the Icom tab ─────────────────── // ── IcomController: receive-DSP controls for the Icom tab ───────────────────
func (b *IcomSerial) IcomState() IcomTXState { func (b *IcomSerial) IcomState() IcomTXState {
// Asking for the snapshot is what marks the console as being watched, which
// is what re-enables the meter polling in ReadState.
b.dspWantMu.Lock()
b.dspWantAt = time.Now()
b.dspWantMu.Unlock()
b.dspMu.Lock() b.dspMu.Lock()
defer b.dspMu.Unlock() defer b.dspMu.Unlock()
return b.dsp return b.dsp
} }
// panelWatched reports whether the Icom console asked for the DSP snapshot
// recently — i.e. whether anything is on screen to read the meters.
func (b *IcomSerial) panelWatched() bool {
b.dspWantMu.Lock()
at := b.dspWantAt
b.dspWantMu.Unlock()
return !at.IsZero() && time.Since(at) < 3*time.Second
}
// RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT // RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT
// goroutine (dispatched via IcomDo). // goroutine (dispatched via IcomDo).
func (b *IcomSerial) RefreshIcom() error { func (b *IcomSerial) RefreshIcom() error {
+5 -2
View File
@@ -196,7 +196,6 @@ func (s *Server) run() {
} }
func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
applog.Printf("udp: [%s] rx %d bytes from %s\n", s.cfg.Name, len(pkt), remote)
ev := Event{ConfigID: s.cfg.ID, Service: s.cfg.ServiceType, Source: remote.String()} ev := Event{ConfigID: s.cfg.ID, Service: s.cfg.ServiceType, Source: remote.String()}
switch s.cfg.ServiceType { switch s.cfg.ServiceType {
case ServiceWSJT: case ServiceWSJT:
@@ -229,8 +228,12 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.Mode = w.Mode ev.Mode = w.Mode
break break
} }
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n", // Only a logged QSO is worth a line — WSJT-X/MSHV send a Status packet
// every second, and logging each one buried the rest of the file.
if len(w.LoggedADIF) > 0 {
applog.Printf("udp: [%s] WSJT QSO logged: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF)) s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
}
ev.DXCall = w.DXCall ev.DXCall = w.DXCall
ev.DXGrid = w.DXGrid ev.DXGrid = w.DXGrid
ev.Mode = w.Mode ev.Mode = w.Mode
+57 -3
View File
@@ -9,6 +9,7 @@ package powergenius
import ( import (
"bufio" "bufio"
"fmt" "fmt"
"math"
"net" "net"
"strconv" "strconv"
"strings" "strings"
@@ -36,6 +37,16 @@ type Status struct {
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
Temperature float64 `json:"temperature"` Temperature float64 `json:"temperature"`
Operate bool `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it) Operate bool `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it)
// Live power, read straight from the amplifier's own status frame rather
// than sampled off the FlexRadio meter stream. PeakW is the amp's own peak
// detector: a poll catches one instant of the envelope, so the plain forward
// figure lands between syllables as often as on a peak.
FwdW float64 `json:"fwd_w"` // forward power [W] (the frame reports dBm)
PeakW float64 `json:"peak_w"` // peak forward power [W]
Vswr float64 `json:"vswr"` // VSWR, derived from the frame's return loss in dB
Id float64 `json:"id"` // drain current [A]
PeakId float64 `json:"peak_id"` // peak drain current [A]
} }
type Client struct { type Client struct {
@@ -226,9 +237,9 @@ func (c *Client) parse(resp string) {
c.statusMu.Lock() c.statusMu.Lock()
c.status.Connected = true c.status.Connected = true
c.status.LastError = "" c.status.LastError = ""
// Log each DISTINCT status payload once: the PGXL's field set isn't fully // One raw frame per session is enough to learn the field set — the frames
// documented, so this is how we learn the real key for e.g. operate/standby. // carry live meter values, so "log on change" logged every frame.
if data != c.lastRaw { if c.lastRaw == "" {
c.lastRaw = data c.lastRaw = data
applog.Printf("pgxl: status raw=%q", data) applog.Printf("pgxl: status raw=%q", data)
} }
@@ -252,7 +263,50 @@ func (c *Client) parse(resp string) {
c.status.FanMode = dev c.status.FanMode = dev
case "temp": case "temp":
c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64) c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64)
case "fwd":
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
c.status.FwdW = dbmToWatts(v)
}
case "peakfwd":
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
c.status.PeakW = dbmToWatts(v)
}
case "swr":
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
c.status.Vswr = returnLossToVswr(v)
}
case "id":
c.status.Id, _ = strconv.ParseFloat(kv[1], 64)
case "peakid":
c.status.PeakId, _ = strconv.ParseFloat(kv[1], 64)
} }
} }
c.statusMu.Unlock() c.statusMu.Unlock()
} }
// dbmToWatts converts a power reading in dBm to watts (0 dBm = 1 mW). The amp
// reports power that way — "fwd=60.5" is 1122 W, not 60 W.
func dbmToWatts(dbm float64) float64 {
if dbm <= 0 {
return 0
}
return math.Pow(10, (dbm-30)/10)
}
// returnLossToVswr converts the amp's "swr" field — a return loss in dB, sent
// negative for a good match — into the VSWR ratio an operator reads.
func returnLossToVswr(swrDb float64) float64 {
rl := math.Abs(swrDb)
if rl <= 0 {
return 0
}
rho := math.Pow(10, -rl/20)
if rho >= 1 {
return 0
}
vswr := (1 + rho) / (1 - rho)
if vswr > 99.9 || math.IsInf(vswr, 0) || math.IsNaN(vswr) {
return 0
}
return vswr
}
+130 -27
View File
@@ -33,13 +33,11 @@ const (
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE (confirmed on hw) cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE (confirmed on hw)
cmdStatus byte = 0x90 // request the status string cmdStatus byte = 0x90 // request the status string
// Best-guess keystroke codes reconstructed from the APG command table after // From the official APG rev 1.1 command table. Note POWER does NOT switch a
// correcting the PDF's note-wrap misalignment (anchored to the confirmed // sleeping amp on (that is the RTS/DTR wake, see PowerOn) — on a running amp
// OPERATE=0x0D): OFF=0x0A, POWER=0x0B. The POWER key doubles as power-on (when // it cycles the output level L→M→H, which is all we use it for.
// the amp is off) and the output-level cycle L→M→H (when it's on) — same as the cmdOff byte = 0x0A // SWITCH OFF key — how PowerOff switches the amp off
// single physical POWER button. To be verified on hardware. cmdPower byte = 0x0B // POWER key — cycles the output power level
cmdOff byte = 0x0A // OFF key — switch the amplifier off
cmdPower byte = 0x0B // POWER key — turn on / cycle output power level
syncHost = 0x55 syncHost = 0x55
syncAmp = 0xAA syncAmp = 0xAA
@@ -47,11 +45,16 @@ const (
dialTimeout = 5 * time.Second dialTimeout = 5 * time.Second
ioTimeout = 3 * time.Second ioTimeout = 3 * time.Second
pollEvery = 800 * time.Millisecond pollEvery = 800 * time.Millisecond
// How long a modem line is held LOW before being raised again, to give the
// amp's edge-triggered remote-on input a transition it can see.
wakePulse = time.Second
) )
// Status is the decoded amplifier state for the UI. // Status is the decoded amplifier state for the UI.
type Status struct { type Status struct {
Connected bool `json:"connected"` Connected bool `json:"connected"`
Transport string `json:"transport"` // "serial" | "tcp" — the UI enables power-ON on serial even when asleep
LastError string `json:"last_error,omitempty"` LastError string `json:"last_error,omitempty"`
Model string `json:"model,omitempty"` // "20K" / "13K" Model string `json:"model,omitempty"` // "20K" / "13K"
Operate bool `json:"operate"` // true = OPERATE, false = STANDBY Operate bool `json:"operate"` // true = OPERATE, false = STANDBY
@@ -91,6 +94,8 @@ type Client struct {
stop chan struct{} stop chan struct{}
running bool running bool
lastConnErr string // last connect failure logged (log only on change)
} }
func New(cfg Config) *Client { func New(cfg Config) *Client {
@@ -123,7 +128,13 @@ func (c *Client) Stop() {
func (c *Client) GetStatus() Status { func (c *Client) GetStatus() Status {
c.statusMu.RLock() c.statusMu.RLock()
defer c.statusMu.RUnlock() defer c.statusMu.RUnlock()
return c.status s := c.status
if c.cfg.Transport == "tcp" {
s.Transport = "tcp"
} else {
s.Transport = "serial"
}
return s
} }
func (c *Client) setErr(err error) { func (c *Client) setErr(err error) {
@@ -146,27 +157,79 @@ func (c *Client) Operate(on bool) error {
// ToggleOperate flips STANDBY/OPERATE unconditionally. // ToggleOperate flips STANDBY/OPERATE unconditionally.
func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) } func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) }
// PowerOn turns the amplifier on. Per the SPE manual this is NOT a data command // PowerOn switches the amplifier on with a RISING EDGE on RTS, then on DTR.
// but a hardware DTR pulse on the serial line (the "Remote_ON" pin), which needs //
// the special SPE cable — so it only applies to the serial transport. Over TCP // Confirmed on a live 1.3K-FA. The remote-on input is EDGE-triggered, which is
// there is no DTR line and power-on isn't possible remotely. // why holding the lines high does nothing: a plain port open already leaves both
// high (the serial library's default), so there is no transition left to give.
// Hence low → pause → high on each line, in that order. The keystroke route is a
// dead end — a switched-off amp does not answer its UART at all, and the POWER
// key (0x0B) merely cycles L/M/H on an amp that is already running.
//
// Serial only: over TCP there are no modem lines. The port is (re)opened first,
// since the poll loop has dropped the connection to a sleeping amp long before
// the user clicks ON.
func (c *Client) PowerOn() error { func (c *Client) PowerOn() error {
c.mu.Lock() if c.cfg.Transport == "tcp" {
defer c.mu.Unlock() return fmt.Errorf("power-on needs the serial RTS/DTR lines; not available over a network bridge")
sp, ok := c.conn.(serial.Port)
if !ok {
return fmt.Errorf("power-on needs the serial DTR line (special SPE cable); not available over network")
} }
// A single positive pulse (~1.2s) on DTR, as the manual describes. // On an amp that is already awake there is nothing to wake.
if err := sp.SetDTR(true); err != nil { if c.GetStatus().Connected {
return nil
}
// The poll goroutine may still be inside a blocking read on the old handle;
// Windows keeps the port "busy" until that read times out (ioTimeout). Retry
// the open for a little longer than that.
var err error
deadline := time.Now().Add(ioTimeout + 3*time.Second)
for {
if err = c.ensureConn(); err == nil || time.Now().After(deadline) {
break
}
time.Sleep(200 * time.Millisecond)
}
if err != nil {
applog.Printf("spe: power ON failed — cannot open %s: %v", c.cfg.ComPort, err)
return fmt.Errorf("power-on: %w", err)
}
c.mu.Lock()
sp, ok := c.conn.(serial.Port)
c.mu.Unlock()
if !ok {
return fmt.Errorf("power-on needs a serial connection")
}
pulse := func(set func(bool) error) error {
if err := set(false); err != nil {
return err return err
} }
time.Sleep(1200 * time.Millisecond) time.Sleep(wakePulse)
return sp.SetDTR(false) return set(true)
}
if err = pulse(sp.SetRTS); err == nil {
err = pulse(sp.SetDTR)
}
// Booting, the amp re-enumerates its USB interface, which leaves this handle
// pointing at a device that no longer exists — the amp came up and OpsLog
// still read "offline". Drop it; the poll loop reopens a fresh one as soon as
// the port is back.
c.mu.Lock()
c.dropLocked()
c.mu.Unlock()
applog.Printf("spe: power ON — RTS then DTR pulsed on %s (err=%v)", c.cfg.ComPort, err)
return err
} }
// PowerOff presses the OFF key (switches the amp off). // PowerOff presses the SWITCH OFF key (0x0A) — the amp is running, so it hears
func (c *Client) PowerOff() error { return c.sendCmd(cmdOff) } // its UART. Dropping DTR then RTS switches it off too (it is the mirror of the
// wake, and does work when done by hand), but back-to-back from one click it
// did not, while this keystroke has never once failed. The amp then stays off:
// the poll loop keeps reopening the port with both lines high and that has
// never woken it — only the deliberate low→high sequence in PowerOn does.
func (c *Client) PowerOff() error {
err := c.sendCmd(cmdOff)
applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err)
return err
}
// SetPowerLevel cycles the output power level (L/M/H) to the requested one by // SetPowerLevel cycles the output power level (L/M/H) to the requested one by
// tapping the POWER key (0x0B) and WAITING for the amp to actually report the new // tapping the POWER key (0x0B) and WAITING for the amp to actually report the new
@@ -206,9 +269,16 @@ func (c *Client) pollLoop() {
return return
case <-t.C: case <-t.C:
if err := c.ensureConn(); err != nil { if err := c.ensureConn(); err != nil {
// Logged once per distinct message: "the amp is on but OpsLog
// says offline" was impossible to diagnose without the reason.
if msg := err.Error(); msg != c.lastConnErr {
c.lastConnErr = msg
applog.Printf("spe: connect %s failed: %s", c.cfg.ComPort, msg)
}
c.setErr(fmt.Errorf("connect: %w", err)) c.setErr(fmt.Errorf("connect: %w", err))
continue continue
} }
c.lastConnErr = ""
// The amp streams status frames faster than one per poll, so a backlog // The amp streams status frames faster than one per poll, so a backlog
// builds up and we'd read stale frames (the display lagged ~15s behind // builds up and we'd read stale frames (the display lagged ~15s behind
// the real amp). Flush anything pending, THEN request + read exactly one // the real amp). Flush anything pending, THEN request + read exactly one
@@ -239,16 +309,48 @@ func (c *Client) ensureConn() error {
nc, err = net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout) nc, err = net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
rwc = nc rwc = nc
} else { } else {
rwc, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud}) var sp serial.Port
// Plain open: both modem lines come up high (the library's default), which
// is what the interface needs to talk. That is not enough to switch the amp
// on — the remote-on input wants the deliberate low→high sequence PowerOn
// sends — so reconnecting never wakes an amplifier the operator switched
// off, and forcing either line low here would switch a running one off.
sp, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
if err == nil {
// Without this, a read on a sleeping amp blocks forever inside the
// Windows serial driver — the poll goroutine survived the window and
// the process took ~a minute to die.
_ = sp.SetReadTimeout(ioTimeout)
rwc = sp
}
} }
if err != nil { if err != nil {
return err return err
} }
c.conn = rwc c.conn = rwc
if sp, ok := rwc.(serial.Port); ok {
// The driver reports a read timeout as "0 bytes, no error", which bufio
// spins on; surface it as a real error so the poll loop drops and retries.
c.r = bufio.NewReader(serialTimeoutReader{sp})
} else {
c.r = bufio.NewReader(rwc) c.r = bufio.NewReader(rwc)
}
return nil return nil
} }
// serialTimeoutReader converts the serial driver's timeout convention (n=0,
// err=nil once SetReadTimeout expires) into an explicit error, so a bufio
// reader fails fast instead of retrying an amp that is asleep.
type serialTimeoutReader struct{ p serial.Port }
func (s serialTimeoutReader) Read(b []byte) (int, error) {
n, err := s.p.Read(b)
if n == 0 && err == nil {
return 0, fmt.Errorf("spe: read timeout")
}
return n, err
}
func (c *Client) dropLocked() { func (c *Client) dropLocked() {
if c.conn != nil { if c.conn != nil {
c.conn.Close() c.conn.Close()
@@ -361,9 +463,10 @@ func (c *Client) decodeCSV(payload string) {
c.statusMu.Lock() c.statusMu.Lock()
defer c.statusMu.Unlock() defer c.statusMu.Unlock()
// Log the raw status frame ONCE per change, so field alignment can be verified // One raw frame per session is enough to verify field alignment against real
// against real hardware (the doc's 19-field layout may differ per firmware). // hardware the frame carries live meter values, so logging each change
if payload != c.lastRaw { // meant logging nearly every frame.
if c.lastRaw == "" {
c.lastRaw = payload c.lastRaw = payload
applog.Printf("spe: status raw=%q fields=%d", payload, len(f)) applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
} }
+11 -1
View File
@@ -61,6 +61,7 @@ type Status struct {
FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported
FwdW float64 `json:"fwd_w"` // forward power [W], derived from dBm FwdW float64 `json:"fwd_w"` // forward power [W], derived from dBm
PeakW float64 `json:"peak_w"` // the device's own peak forward power [W] — what the meters show
SwrDb float64 `json:"swr_db"` // return loss [dB] as reported (negative = good match) SwrDb float64 `json:"swr_db"` // return loss [dB] as reported (negative = good match)
Vswr float64 `json:"vswr"` // VSWR ratio, derived from swr_db (1.0 = perfect) Vswr float64 `json:"vswr"` // VSWR ratio, derived from swr_db (1.0 = perfect)
@@ -405,7 +406,9 @@ func (c *Client) parse(resp string) {
if !strings.HasPrefix(data, "status") { if !strings.HasPrefix(data, "status") {
return return
} }
if data != c.lastRaw { // One raw frame per session is enough to verify field alignment — the
// frames carry live meter values, so "log on change" logged every frame.
if c.lastRaw == "" {
c.lastRaw = data c.lastRaw = data
applog.Printf("tunergenius: status raw=%q", data) applog.Printf("tunergenius: status raw=%q", data)
} }
@@ -439,6 +442,13 @@ func (c *Client) applyStatus(data string) {
c.status.FwdDbm = v c.status.FwdDbm = v
c.status.FwdW = dbmToWatts(v) c.status.FwdW = dbmToWatts(v)
} }
// The device's OWN peak detector. A poll samples one instant of the envelope,
// so on SSB the plain "fwd" figure lands between syllables as often as on a
// peak — the widget read a few hundred watts on a kilowatt transmission. This
// is the number to display.
if v, ok := parseFloat(kv["peak"]); ok {
c.status.PeakW = dbmToWatts(v)
}
if v, ok := parseFloat(kv["swr"]); ok { if v, ok := parseFloat(kv["swr"]); ok {
c.status.SwrDb = v c.status.SwrDb = v
c.status.Vswr = returnLossToVswr(v) c.status.Vswr = returnLossToVswr(v)
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.22.8" appVersion = "0.22.9"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.
+55 -9
View File
@@ -1,13 +1,47 @@
# Amplifiers and Switches # Amplifiers and Switches
## PowerGenius XL (4O3A) amplifier ## Amplifiers
Direct TCP connection (Settings → PowerGenius): Settings → **Amplifier** is a **list**: configure several amps (e.g. two SPEs
run in parallel), each with its own name and connection. Brands:
- **Operate / Standby** and **fault** display. - **SPE Expert** (1.3K / 1.5K / 2K) — USB serial or an RS232-to-Ethernet
- **Fan-mode** selector (Standard / Contest / Broadcast). bridge. OPERATE/STANDBY, ON/OFF, Low/Mid/High power level, live status.
- When a Flex is connected, the amp controls + its **FWD / ID / TEMP** meters Switching the amplifier **on and off** uses the remote-on control lines, so
appear in the FlexRadio panel's Amplifier card. See [[FlexRadio]]. it needs the **USB (or RS-232) connection**, not a network bridge — the ON
button works even while the amplifier is off and reporting nothing.
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) — serial or bridge.
OPERATE/STANDBY/OFF, live power / SWR / PA temperature / band / fan.
Power-ON works over serial when the cable wires the DTR/RTS pins.
- **PowerGenius XL** (4O3A) — direct TCP. Operate/Standby, fault display, and a
**fan-mode** selector (Standard / Contest / Broadcast).
Where the controls appear:
- An **amplifier card** in the FlexRadio panel and in **Station Control**:
OPERATE/STANDBY, ON/OFF, power level, output-power bar and live FWD /
drain-current / temperature meters. Several amps → one card each.
- A **status chip per amp** in the bottom bar: green ON AIR = OPERATE, orange =
STANDBY, red = offline. Clicking it toggles OPERATE/STANDBY.
- A **docked widget** (flame icon, next to the keyers): Operate, power ON/OFF,
the SPE power level and live watts / SWR / temperature, in the narrow column
format of the Tuner Genius widget. With several amplifiers configured, a
dropdown on the icon picks which one it shows — or **All**.
### Band-follow (amp on a second COM port)
An ACOM or SPE that normally polls the radio for its frequency can follow
OpsLog instead: OpsLog answers the amp's polls **as a Kenwood rig** on a second
COM port (Settings → Amplifier → frequency port). Useful when the real radio's
CAT port is already taken by OpsLog itself.
## Tuner Genius XL (4O3A)
Settings → Tuner Genius (IP only; port fixed at 9010). Live **SWR and forward
power** with peak-hold, **Tune / Bypass / Operate-Standby**, and the two
channels A/B (source, frequency, antenna) shown and click-selectable. Available
as a docked widget, a card in the FlexRadio panel and a card in Station
Control. Direct TCP — uses one of the box's connection slots.
## Antenna Genius (4O3A) antenna switch ## Antenna Genius (4O3A) antenna switch
@@ -16,8 +50,20 @@ Over TCP / GSCP — a docked **A/B antenna-switch** widget:
- One row per configured antenna, with a **Port A** and **Port B** button. - One row per configured antenna, with a **Port A** and **Port B** button.
- Colours: green = selected on port A, blue = selected on port B, red (pulsing) = - Colours: green = selected on port A, blue = selected on port B, red (pulsing) =
that port is transmitting. Clicking a selected port deselects it (→ None). that port is transmitting. Clicking a selected port deselects it (→ None).
- **Band filter** (funnel icon in the header): shows only the antennas configured - **Band filter** (funnel icon): shows only the antennas configured for the
for the current band, like the native app. Toggle it off to see all antennas. current band. Antennas with an unknown band mask are always shown.
Antennas with an unknown band mask are always shown.
Configure the host / password in Settings → Antenna Genius. Configure the host / password in Settings → Antenna Genius.
## Relay boards (Station Control)
USB relay boards for antenna/accessory switching, with named buttons in Station
Control: **Denkovi** (4/8 relays, FT245) and generic **CH340 / LCUS**
USB-serial boards. A Test-connection button and detection feedback live in the
setup panel.
## Station Control
The Station Control tab is a dashboard of fixed-width panels that wrap to fill
the window — amplifier(s), tuner, relays, rotator, Ultrabeam… Drag a panel by
the grip on its left edge to reorder; the order is remembered.
+53 -16
View File
@@ -2,35 +2,72 @@
## CW keyer ## CW keyer
A CW keyer with **macros** and F-key macros. The keyer **engine** is selectable A CW keyer with **macros** on F-keys (F1F9; empty slots are hidden). The keyer
(Settings → CW Keyer): **engine** is selectable (Settings → CW Keyer):
- **WinKeyer** — K1EL WK1/2/3 over a COM port. - **WinKeyer** — K1EL WK1/2/3 over a COM port. The log names the generation
detected, and a byte-level trace can be enabled for bug reports.
- **Serial port (DTR=CW / RTS=PTT)** — OpsLog keys CW by toggling a serial
control line itself, the hardware method N1MM/WSJT-X use with a Yaesu SCU-17
or a generic keying interface. No WinKeyer needed. Choose the COM port, which
line carries CW, and *Invert keying* for interfaces that key backwards. This
is also the engine for an **FTDX10** (whose CAT refuses `KY`), on the rig's
second COM port.
- **Icom** — the radio's own keyer over CI-V, no extra hardware. Works over the - **Icom** — the radio's own keyer over CI-V, no extra hardware. Works over the
remote link too. (Requires the Icom CAT backend; set break-in to SEMI/FULL or remote link too. (Set break-in to SEMI/FULL or the rig keys the sidetone but
the rig keys the sidetone but stays in RX.) stays in RX.)
- **FlexRadio (CWX)** — keys CW straight over the SmartSDR connection, with
type-ahead and mid-send backspace.
- **Yaesu (rig keyer)** — the radio's internal keyer over CAT (`KY`), for the
FTDX101 / FT-991A / FT-710 family. See [[Yaesu]].
- **TCI** — for TCI backends. - **TCI** — for TCI backends.
**ESC** stops CW on whichever engine is active; typing a callsign aborts a
running macro. `<LOGQSO>` inside a macro logs the contact at that exact point.
**Auto-call** repeats a CQ with the gap you set measured *after* the message.
### ESM — Enter Sends Message
N1MM-style, for CW (Settings → CW Keyer). With the keyer on, **Enter** fires a
macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign
entered → F2 (report), focus jumps to RST; Enter in RST → F3 (TU), which logs
if the macro contains `<LOGQSO>`.
## Digital Voice Keyer (DVK) ## Digital Voice Keyer (DVK)
Record **F1F6** voice messages and transmit them. Set the audio devices, PTT Record **F1F6** voice messages and transmit them. Set the audio devices, PTT
method and gains in Settings → Audio. method and gains in Settings → Audio.
- **PTT** method: CAT, RTS, DTR, or none (VOX). - **PTT** method: CAT (whichever backend is active — every one supports PTT),
- **Test PTT** keys the rig briefly. On CAT/OmniRig the key is held ~1.5 s so the RTS, DTR, or none (VOX).
transmit command is actually sent (OmniRig is asynchronous and coalesces rapid - **Auto CQ** repeats a CQ-labelled message on a timer until you stop it.
writes). - The DVK refuses to transmit on non-phone modes — no speech on FT8.
- A **message level** control (Settings → Audio) drives the rig at the right
level even from a quietly recorded microphone. If the rig transmits almost
nothing, check its rear-input setting (on an FTDX10: SSB MOD SOURCE = REAR).
## QSO audio recording ## QSO audio recording
Continuous rolling capture. On **Log QSO** the contact is saved to a per-QSO WAV Continuous rolling capture. On **Log QSO** the contact is saved to a per-QSO
(`CALL_YYYYMMDD_HHMMSS.wav`), mixing RX + mic. Configure the recordings folder, WAV/MP3 (`CALL_YYYYMMDD_HHMMSS`), with pre-roll. With automatic recording off, a
format (WAV / MP3), pre-roll and gains in Settings → Audio. **red dot** beside the callsign records the contact by hand.
A recording can be **replayed on the air** to the station being worked, like a
voice-keyer message — with its **own level**, separate from the voice keyer (a
receiver line-out needs far less gain than a microphone). The play button
becomes a **stop** button while it transmits. Hidden on CW.
## CW decoder (RX audio → text)
**Tools → CW decoder** (or the ear button) decodes received CW while the mode
is CW: adaptive envelope with noise squelch, per-character dit/dah
classification, follows 1240 WPM and sloppy fists, rides QSB. Shows WPM /
pitch / level, locks onto the FlexRadio CW pitch automatically, and clicking a
decoded word fills the callsign. Clean-to-moderate signals decode solidly;
weak-signal pileups remain CW Skimmer territory.
## RX audio monitor (USB rigs) ## RX audio monitor (USB rigs)
Settings → Audio → **Listen to radio** pipes the rig's received audio (its "USB Settings → Audio → **Listen to radio** pipes the rig's received audio (its "USB
Audio CODEC" input) through your speakers, so you hear the radio inside OpsLog. Audio CODEC" input) through your speakers — the level applies live as you drag
**Talk to radio** keys PTT and pipes your mic to the rig's audio input. (Network it. **Talk to radio** keys PTT and pipes your mic to the rig's audio input.
audio for remote Icom is a work in progress — see
[[Remote Icom over the Internet]].)
+18 -4
View File
@@ -6,8 +6,15 @@ shared **globally** across profiles.
## Built-in awards ## Built-in awards
DXCC, WAS / WAZ / WAC, WPX, IOTA / POTA / SOTA / WWFF, **DDFM** (French DXCC, WAS / WAZ / WAC, WPX, IOTA / POTA / SOTA / WWFF, **DDFM** (French
departments), and more — tracked **worked / confirmed / validated** by band and departments), **DLD** (DARC DOK), **H26** (Helvetia 26 cantons), the US
mode. counties (USA-CA), and more — tracked **worked / confirmed / validated** by
band and mode.
The awards panel has a **mode filter** (All / CW / Phone / Digital) that
applies to the whole award — bands, counts, confirmations and the contacts
behind a cell — and stacks with the worked/confirmed filter, so "worked on CW
but not confirmed on CW" is one click. Reference lists sort by reference or by
description.
## How matching works ## How matching works
@@ -121,13 +128,20 @@ drop off the list. This is how you close the gaps a matcher can't fill on its ow
## Live detection & manual refs ## Live detection & manual refs
- References are detected **live** as you enter a callsign. - References are detected **live** as you enter a callsign — from the looked-up
address *and* from the QTH, name, country, comment, note or grid you typed.
- An award can be marked **one reference per QSO** (used by DLD). When several
references then match the same contact, none is assigned silently — the
candidates are offered at log time and you pick. Off by default, so an n-fer
POTA activation still counts every park.
- You can **manually assign** a reference to a QSO (the *Award Refs* tab of the - You can **manually assign** a reference to a QSO (the *Award Refs* tab of the
QSO editor). It's stored in an ADIF extra (`APP_OPSLOG_AWARDREFS`) so it QSO editor). It's stored in an ADIF extra (`APP_OPSLOG_AWARDREFS`) so it
survives export/import — see [[Import and Export ADIF]] — and is honoured survives export/import — see [[Import and Export ADIF]] — and is honoured
everywhere (award panel, grid columns, totals). For a list-backed award the everywhere (award panel, grid columns, totals). For a list-backed award the
assigned reference must still be a **valid, listed** reference, and the QSO assigned reference must still be a **valid, listed** reference, and the QSO
must be **in scope** (rule 5) for it to count. must be **in scope** (rule 5) for it to count. A hand-assigned reference
**replaces** what the matcher found, so a correction survives the next
recompute.
## Reference lists & display ## Reference lists & display
+52 -14
View File
@@ -1,28 +1,46 @@
# CAT Control # CAT Control
OpsLog has **four native CAT backends** (Settings → CAT). Each auto-reconnects OpsLog has **eight CAT backends** (Settings → CAT). Each auto-reconnects and
and connects *non-blocking*, so a powered-off radio never freezes the app. connects *non-blocking*, so a powered-off radio never freezes the app. Backends
are named by **how the radio is reached**:
| Backend | Use it for | | Backend | Use it for |
|---------|-----------| |---------|-----------|
| **OmniRig** | Any OmniRig-supported rig (Rig 1 / Rig 2, hot-swap). | | **OmniRig** | Any OmniRig-supported rig (Rig 1 / Rig 2, hot-swap). |
| **FlexRadio (SmartSDR)** | FlexRadio 6000/8000 over the TCP API. → [[FlexRadio]] | | **FlexRadio (API)** | FlexRadio 6000/8000 over the TCP API. → [[FlexRadio]] |
| **Icom CI-V** | Any Icom over **USB** *or* over the internet via its **LAN server**. → [[Icom]] · [[Remote Icom over the Internet]] | | **Yaesu (USB)** | FTDX10 / FTDX101 family, direct serial — no OmniRig. → [[Yaesu]] |
| **Kenwood (USB, network)** | TS-590 / TS-890 / TS-990 / TS-2000 (Elecraft K3/K4 speak the same dialect), serial or a `host:port` serial bridge. → [[Kenwood and Xiegu]] |
| **Xiegu (USB)** | G90 / X6100 / X6200 / X5105 — CI-V with their reduced command set. → [[Kenwood and Xiegu]] |
| **Icom (CI-V USB)** | Any Icom over USB. → [[Icom]] |
| **Icom (CI-V network)** | An Icom's built-in LAN/internet server, replacing RS-BA1. → [[Remote Icom over the Internet]] |
| **TCI** | SunSDR / ExpertSDR2 and any TCI (WebSocket) server. | | **TCI** | SunSDR / ExpertSDR2 and any TCI (WebSocket) server. |
Once connected, frequency / band / mode follow the radio, clicking a cluster spot Once connected, frequency / band / mode follow the radio, clicking a cluster
tunes the rig, and (for Flex) cluster spots can be pushed to the panadapter. spot tunes the rig, and (for Flex) cluster spots can be pushed to the panadapter.
## Sharing the rig with WSJT-X / JTDX / MSHV (rigctld)
A native backend holds the radio's serial port **exclusively** — so OpsLog can
serve its rig connection to other programs instead: **Settings → CAT → Share
CAT**. In WSJT-X / JTDX / MSHV / Log4OM pick rig model **Hamlib NET rigctl** at
`127.0.0.1:4532`. Works with every backend and both Hamlib command dialects.
> Never point two programs at the same COM port. If another logger or WSJT-X
> opens the rig's port directly while OpsLog holds it, you get "port busy"
> errors, garbled CI-V frames and erratic rig behaviour. One master — OpsLog —
> and everyone else through the shared rigctl port.
## OmniRig ## OmniRig
Pick **Rig 1** or **Rig 2** (the two OmniRig slots). Configure the actual COM Pick **Rig 1** or **Rig 2** (the two OmniRig slots). Configure the actual COM
port / baud in OmniRig's own settings. Works with anything OmniRig supports; PTT port / baud in OmniRig's own settings.
keying via CAT is supported where the rig's OmniRig profile exposes it.
## FlexRadio - **VFO to read** — some OmniRig rig files declare the wrong active VFO (an
IC-7610 file declared VFO B). If the frequency looks frozen or follows the
Enter the radio's IP (UDP discovery assists). You get a full SmartSDR-style wrong VFO, force **A** or **B** here.
control tab — see **[[FlexRadio]]**. - OpsLog and OmniRig must run at the **same privilege level** — one elevated
("as administrator") and not the other cannot see each other; OpsLog detects
and names this case.
## Icom CI-V (USB) ## Icom CI-V (USB)
@@ -31,7 +49,7 @@ control tab — see **[[FlexRadio]]**.
- **COM port** + **baud** must match the radio (set *CI-V USB Echo Back* **OFF** - **COM port** + **baud** must match the radio (set *CI-V USB Echo Back* **OFF**
on the rig). on the rig).
- The model choice also tailors the console (e.g. attenuator steps: 20 dB on an - The model choice also tailors the console (e.g. attenuator steps: 20 dB on an
IC-7300, 6/12/18 dB on an IC-7610). IC-7300, 6/12/18 dB on an IC-7610/7800).
Full console details: **[[Icom]]**. Full console details: **[[Icom]]**.
@@ -40,10 +58,30 @@ Full console details: **[[Icom]]**.
Enter the TCI host + port (default 40001). Gives freq / mode / PTT / split and, Enter the TCI host + port (default 40001). Gives freq / mode / PTT / split and,
optionally, panorama spots. optionally, panorama spots.
## Serial lines (DTR / RTS)
Windows raises the DTR and RTS lines when a serial port opens, and some
interfaces read one of them as **PTT** — the radio goes into transmit the moment
OpsLog connects. Others need the lines **high** to work at all. So:
- **Xiegu** always lowers both on connect (a G90 behind a DE-19 keyed on open),
and has a setting for which line keys the rig.
- **Yaesu and Kenwood** have an option (off by default) to lower both lines on
connect — turn it on only if your interface transmits as soon as OpsLog opens
the port.
- **Icom CI-V** has always dropped them.
## Protocol trace
Settings → CAT can log the **raw protocol** (every CI-V frame, or every Kenwood
command, as hex/text) to the diagnostic log — invaluable when reporting a radio
that answers oddly. The checkbox shows whether the trace is really running.
## Notes ## Notes
- The **digital sub-mode** (FT4 vs FT8) is inferred from the frequency. - The **digital sub-mode** (FT4 vs FT8) is inferred from the frequency.
- A radio reporting LSB or USB selects **SSB** in the entry form.
- **Per-band Flex RX/TX antennas** can be configured and are applied - **Per-band Flex RX/TX antennas** can be configured and are applied
automatically on band change (Settings → FlexRadio). automatically on band change (Settings → CAT → FlexRadio).
- Split, RIT/XIT and other rig-specific features are exposed on each backend's - Split, RIT/XIT and other rig-specific features are exposed on each backend's
control tab where supported. control tab where supported.
+24 -6
View File
@@ -3,27 +3,39 @@
## Servers ## Servers
Add multiple cluster servers (Settings). They auto-reconnect; one is the Add multiple cluster servers (Settings). They auto-reconnect; one is the
**master** used for sending commands. The Cluster tab and the Main-view cluster **master** used for sending commands. A quiet node is not treated as a dead one
pane share the same live feed. — silence keeps its session, login and filters. The Cluster tab and the
Main-view cluster pane share the same live feed.
## Filter sidebar ## Filter sidebar
A show/hide **filter sidebar** (shared by the Cluster tab and the Main pane): A show/hide **filter sidebar** (shared by the Cluster tab and the Main pane):
- **Callsign** search, **hide worked**, **group duplicates**. - **Callsign** search, **hide worked**, **group duplicates**.
- Filter by **band / mode / status / source**. - Filter by **band / mode / status / source**, plus **NEW POTA** and
**NEW COUNTY** chips.
## Per-spot status and tuning ## Per-spot status and tuning
Each spot is coloured by **status** relative to your log: Each spot is coloured by **status** relative to your log:
- **new** (entity never worked), **new-band**, **new-slot**, **worked**. - **new** (entity never worked), **new-band**, **new-mode**, **new-slot**,
**worked**.
- Settings → General → **Group digital modes as one (DXCC-style)** makes
FT8/FT4/RTTY/PSK count as a single Digital mode for the colouring and the
matrix badges, matching how DXCC counts.
Click a spot to **tune the rig** (fills the callsign into the entry strip). A Click a spot to **tune the rig** (fills the callsign into the entry strip). A
multi-band **Band Map** shows panadapter-style strips per band. multi-band **Band Map** shows panadapter-style strips per band — its CW / data
/ phone sub-bands use their own colour-blind-checked hues, and **Ctrl+↑ /
Ctrl+↓** jumps to the next spot above/below the current frequency and tunes it.
**POTA** spots are tagged with their park reference (via `api.pota.app`). **POTA** spots are tagged with their park reference (via `api.pota.app`).
The entry strip's band matrix can also show the **ClubLog Most Wanted rank**
(Settings → General): an `MW #rank` pill next to the entity name, personalised
to your callsign, refreshed daily.
## Sending a spot ## Sending a spot
Use the **Send Spot** window to announce a DX spot on the master cluster: Use the **Send Spot** window to announce a DX spot on the master cluster:
@@ -35,6 +47,12 @@ frequency** (full resolution, including sub-kHz).
**Tools → Alert management** — Log4OM-style alert rules on **call / country / **Tools → Alert management** — Log4OM-style alert rules on **call / country /
band / mode / spotter**, with **sound**, **visual** and **e-mail** notification. band / mode / spotter**, with **sound**, **visual** and **e-mail** notification.
Active alerts appear as a discreet bell in the entry strip; click it for the Active alerts appear as a discreet bell in the entry strip; click it for the
list. Alerts are kept briefly and only shown when active. list.
## External spot sources
A **DXHunter** remote-call UDP packet fills the callsign, and when it carries
`<FREQ>` and `<MODE>` it also tunes whichever CAT backend is active. See
[[Settings and Data]] for the UDP configuration.
See also: [[Maps and Antennas]] for turning the beam to a spot. See also: [[Maps and Antennas]] for turning the beam to a spot.
+8 -6
View File
@@ -1,10 +1,11 @@
# OpsLog # OpsLog
A modern, fast ham-radio logger for **Windows** — Log4OM-style entry, real-time A modern, fast ham-radio logger for **Windows** — Log4OM-style entry, real-time
CAT for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB CAT for **OmniRig**, native **FlexRadio/SmartSDR**, **Icom CI-V** (USB *and*
*and* remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert remote-over-internet, replacing RS-BA1), **Yaesu**, **Kenwood**, **Xiegu** and
Electronics), a DX cluster with spot alerts, awards tracking, maps, contest **TCI** (SunSDR / Expert Electronics) — with **CAT sharing** so WSJT-X & co use
logging, QSL management and a QSL-card designer. the same rig link — a DX cluster with spot alerts, awards tracking, maps,
contest logging, QSL management and a QSL-card designer.
Built with **Wails v2** (Go backend + React/TypeScript frontend), **pure Go** Built with **Wails v2** (Go backend + React/TypeScript frontend), **pure Go**
(no CGO). SQLite holds the configuration; the logbook can be the local SQLite (no CGO). SQLite holds the configuration; the logbook can be the local SQLite
@@ -18,14 +19,15 @@ Developed by **F4BPO**.
1. **[[Installation]]** — download / build and first launch. 1. **[[Installation]]** — download / build and first launch.
2. **[[Getting Started]]** — the entry strip, logging your first QSO, choosing a 2. **[[Getting Started]]** — the entry strip, logging your first QSO, choosing a
profile. profile.
3. **[[CAT Control]]** — connect your radio (OmniRig, FlexRadio, Icom, TCI). 3. **[[CAT Control]]** — connect your radio (OmniRig, FlexRadio, Icom, Yaesu,
Kenwood, Xiegu, TCI).
## What OpsLog does ## What OpsLog does
| Area | Pages | | Area | Pages |
|------|-------| |------|-------|
| Enter and manage QSOs | [[Logging Basics]] · [[Recent QSOs and Filters]] · [[Import and Export ADIF]] | | Enter and manage QSOs | [[Logging Basics]] · [[Recent QSOs and Filters]] · [[Import and Export ADIF]] |
| Control your radio | [[CAT Control]] · [[FlexRadio]] · [[Icom]] · [[Remote Icom over the Internet]] | | Control your radio | [[CAT Control]] · [[FlexRadio]] · [[Icom]] · [[Remote Icom over the Internet]] · [[Yaesu]] · [[Kenwood and Xiegu]] |
| Chase DX | [[DX Cluster and Spots]] · [[Maps and Antennas]] | | Chase DX | [[DX Cluster and Spots]] · [[Maps and Antennas]] |
| Station hardware | [[Amplifiers and Switches]] · [[Audio and Keyers]] | | Station hardware | [[Amplifiers and Switches]] · [[Audio and Keyers]] |
| Events & contests | [[Contest Logging]] · [[Net Control]] · [[Multi-Operator Live Status]] | | Events & contests | [[Contest Logging]] · [[Net Control]] · [[Multi-Operator Live Status]] |
+18 -6
View File
@@ -8,22 +8,34 @@ export modes.
Import an `.adi` / `.adif` file into the active profile's logbook. On import, Import an `.adi` / `.adif` file into the active profile's logbook. On import,
OpsLog parses every field, maps standard fields to columns and keeps unknown but OpsLog parses every field, maps standard fields to columns and keeps unknown but
valid ADIF fields in a per-QSO **extras** store (editable later). Country / zones valid ADIF fields in a per-QSO **extras** store (editable later). Country /
are enriched from `cty.dat` where missing. zones are enriched from `cty.dat` where missing. A file **without a header**
(records pasted from another log) imports fine.
- **Field remapping**: the import dialog can move a field as it reads it —
e.g. the RSGB IOTA contest exports the island reference in `STATE`; add a
`STATE → IOTA` row and it lands where the award looks.
- **Fill from profile**: optionally stamps your station fields *and* your
default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog,
QRZ.com) on QSOs the file leaves empty — a WSJT-X log carries almost none.
Existing values are never overwritten.
## Export ## Export
- **Whole log**, **selected rows**, or the **filtered view** (no limit) — see - **Whole log**, **selected rows**, or the **filtered view** (no limit) — see
[[Recent QSOs and Filters]]. [[Recent QSOs and Filters]].
- **Standard** mode exports the common promoted fields; **All** mode also exports - **Standard** mode exports the common promoted fields; **All** mode also
every extra field. exports every extra field; **Choose fields…** opens a picker that separates
the official ADIF 3.1.7 fields (by category) from the OpsLog / non-standard
tags actually present in your log, with All/None per group. Your selection is
remembered.
- **Cabrillo** export for contest submission. - **Cabrillo** export for contest submission.
## Round-trip safety ## Round-trip safety
Award references you assign manually are stored in an ADIF extra field Award references you assign manually are stored in an ADIF extra field
(`APP_OPSLOG_AWARDREFS`), so exporting your full log and re-importing it does not (`APP_OPSLOG_AWARDREFS`), so exporting your full log and re-importing it does
lose your award-reference assignments. not lose your award-reference assignments.
## Tips ## Tips
+49
View File
@@ -0,0 +1,49 @@
# Kenwood and Xiegu (native CAT)
## Kenwood (USB, network)
A native backend talks straight to a **TS-590 / TS-890 / TS-990 / TS-2000**
over its serial port — frequency, mode, VFO, split and PTT, no OmniRig in
between. **Elecraft K3/K4** speak the same dialect. Pick **Kenwood (USB,
network)** in Settings → CAT.
- **Serial**: COM port + baud.
- **Network**: give a `host:port` instead — for a serial bridge (ser2net, an
Ethernet-serial adapter). *Not* the radio's own RJ45, which speaks Kenwood's
KNS/ARCP protocol.
- Split is detected from FR/FT when the radio omits it from its status frame.
- The backend was tested end-to-end against a TS-2000 emulator and reports
useful errors: a port that opens but stays silent is distinguished from one
sending data that never answers (the error quotes what arrived).
## Xiegu (G90, X6100, X6200, X5105)
These radios speak CI-V but with a **reduced command set** (no scope, no DSP
block, no data mode), so they have their own backend — pick **Xiegu (USB)** in
Settings → CAT. Frequency, mode, split and PTT.
### PTT on a G90 — read this
A **G90 ignores the CI-V PTT command**. That has three consequences:
1. In the Xiegu settings, choose **how the rig is keyed**: CI-V command, RTS or
DTR — pick the hardware line your interface wires to PTT.
2. WSJT-X & co transmit through OpsLog's **shared CAT link** (Hamlib NET rigctl
`127.0.0.1:4532` — see [[CAT Control]]): OpsLog converts the PTT request
into the hardware line. This is the supported path and it works.
3. **WSJT-X + OmniRig will NOT transmit** a G90: OmniRig sends PTT as a CI-V
command, which the radio ignores. Either use the native Xiegu backend +
rigctl sharing, or configure the PTT line (RTS/DTR) inside OmniRig itself.
### Keying on connect
Behind an interface that carries PTT on a modem line (e.g. a DE-19), a G90 used
to go **straight into transmit when OpsLog connected** — Windows raises DTR and
RTS when a serial port opens. The Xiegu backend now drops both lines on
connect, then drives only the line you selected for keying.
## Notes
- Both backends refuse a CI-V/CAT frame read at the wrong offset, so a garbled
answer can no longer turn into a phantom frequency.
- The protocol trace (Settings → CAT) covers both — see [[CAT Control]].
+31 -8
View File
@@ -10,8 +10,24 @@ save. Fields: callsign, RST tx/rx, name, QTH, grid, band, mode, TX/RX frequency
CQ/ITU zones, continent) from `cty.dat` including `/MM` `/AM` `/B` and call-area CQ/ITU zones, continent) from `cty.dat` including `/MM` `/AM` `/B` and call-area
handling (`/8`, `/W6`), plus **ClubLog** DXpedition date overrides. handling (`/8`, `/W6`), plus **ClubLog** DXpedition date overrides.
- **Callsign lookup** (QRZ.com / HamQTH) fills name / QTH / grid and shows the - **Callsign lookup** (QRZ.com / HamQTH) fills name / QTH / grid and shows the
photo and a QRZ.com tab. Configure it in Settings → Lookup. photo and a QRZ.com tab. Configure it in Settings → Lookup. Portable calls
(`/M`, `/P`) are looked up on the home call too.
- **No lookup account?** The name / QTH / locator are recovered from the **last
time you worked the station**, and for US calls the offline **FCC (ULS)**
database fills state, county and a 6-character grid as you type. A real
QRZ/HamQTH hit still wins.
- A green **NEW** badge appears on the County field (Info / F2) when the US
county has never been worked.
- **ESC** clears the callsign (and stops CW). - **ESC** clears the callsign (and stops CW).
- The frequency readout is **scroll-tunable**: roll the wheel over the
hundreds / tens / units-of-kHz digit and the rig follows.
## Super Check Partial / N+1
Enable in Settings → General (downloads the community MASTER.SCP list). As you
type a call, a docked two-column widget shows known calls that **contain** what
you typed (Partial) and calls **one character away** (N+1) — click one to fix a
busted call.
## Editing a QSO ## Editing a QSO
@@ -22,14 +38,18 @@ fields** editor for any ADIF field not promoted to a column.
## Bulk operations ## Bulk operations
Select rows in Recent QSOs, right-click: Select rows in Recent QSOs (a **Select all** button takes every displayed row),
right-click:
- **Fix country & zones** from `cty.dat`, or **update from QRZ / ClubLog**. - **Fix country & zones** from `cty.dat`, or **update from the callsign
- **Bulk edit field** — set one field across many QSOs (QSL/upload status, my databases** (QRZ / ClubLog), with a progress bar.
station, contest, propagation, contacted-station refs, comment…). Great for - **Bulk edit field** — set one field across many QSOs: QSL/confirmation
flipping a batch to *Requested* before an upload. statuses *and dates*, my station, contest, propagation, contacted-station
refs, comment, **gridsquare**, even the **frequency** (band recomputed,
out-of-band values refused).
- **Send to** a QSL service, **export** selected / filtered to ADIF or Cabrillo, - **Send to** a QSL service, **export** selected / filtered to ADIF or Cabrillo,
**delete**. **delete** (optionally withdrawing the QSO from QRZ.com and Club Log —
Settings → External services, off by default).
## Find duplicates ## Find duplicates
@@ -40,6 +60,9 @@ same day or same minute) and lets you pick which to delete.
- **Worked-before matrix** shows prior contacts with the entity per band/mode - **Worked-before matrix** shows prior contacts with the entity per band/mode
slot as you type. slot as you type.
- Awards references are detected **live** as you enter a call (see [[Awards]]). - Award references are detected **live** as you enter a call — including from
the QTH, name, comment or grid you typed (see [[Awards]]).
- The callsign in the top bar is the **station switcher**: click it to change
profile.
See also: [[Recent QSOs and Filters]] · [[Import and Export ADIF]] See also: [[Recent QSOs and Filters]] · [[Import and Export ADIF]]
+27 -15
View File
@@ -3,36 +3,48 @@
## Main view ## Main view
The Main view is **two configurable panes** (per profile, Settings → General → The Main view is **two configurable panes** (per profile, Settings → General →
*Main view*). Choose from: great-circle map, locator (street) map, the cluster *Main view*), with a **draggable divider** between them (double-click it to
grid, the worked-before grid, recent QSOs, the **FlexRadio** controls, the even them out). Choose from: great-circle map, locator (street) map, the
**Icom** console, or the **Net control** panel. cluster grid, the worked-before grid, recent QSOs, the **FlexRadio** controls,
the **Icom** or **Yaesu** console, or the **Net control** panel.
## Great-circle map ## Great-circle map
- Short/long-path **distance & azimuth** to the worked station. - Short/long-path **distance & azimuth** to the worked station.
- Selectable, **key-free** basemaps: Light / Voyager / Street / Satellite (all - Selectable, **key-free** basemaps: Light / Voyager / Street / Satellite.
labelled).
- The **antenna beam lobe(s)** are drawn from the rotor azimuth. - The **antenna beam lobe(s)** are drawn from the rotor azimuth.
- **Day/night terminator** and twilight band (button at the top right).
## Locator (street) map ## Locator (street) map
An OSM street map centred on the contacted grid square — handy for local / An OSM street map centred on the contacted grid square — handy for local /
portable contacts. No API key. portable contacts. No API key.
## Rotor compass ## Rotators
An azimuthal-equidistant **click-to-turn** compass driven by **PstRotator**. The An azimuthal-equidistant **click-to-turn** compass; the current heading and
current heading and target are shown; click a bearing to turn the rotor. target are shown. Three rotator types (Settings → Rotator):
## Ultrabeam - **PstRotator** — via its UDP interface.
- **microHAM ARCO** — native, over LAN or USB: set the ARCO's CONTROL PROTOCOL
to *Yaesu GS-232A*.
- **GS-232A (generic)** — any GS-232A controller, ERC (Easy Rotor Control)
included; serial speed selectable. Set an ERC to GS-232 emulation, not
Hy-Gain DCU-1.
Ultrabeam antennas are supported with three modes — **Normal**, **180° reverse**, ## Motorized antennas (Ultrabeam / SteppIR)
Both follow the **rig frequency** (band change → retune) and retune immediately
when you click a spot, with three modes — **Normal**, **180° reverse**,
**Bidirectional**: **Bidirectional**:
- The **radiating direction** is shown in green and the **mechanical boom** in - The **radiating direction** is shown in green and the **mechanical boom** in
grey, on both the compass and the map, so you always know where the antenna grey, on both the compass and the map.
actually points. - On a FlexRadio, transmit is **inhibited while the elements move**, and
- The antenna **follows the rig frequency** (band change → retune), and retunes released as soon as the antenna is tuned.
immediately when you click a spot. - **SteppIR — Tunable range** (Settings → Hardware → Antenna, default
1354 MHz): outside it OpsLog leaves the antenna and the TX interlock
completely alone, so tuning to 30 m on a 20 m6 m SteppIR does nothing.
Related hardware: [[Amplifiers and Switches]] (Antenna Genius switch). Related hardware: [[Amplifiers and Switches]] (Antenna Genius switch, Tuner
Genius, relays).
+28 -10
View File
@@ -6,18 +6,29 @@ Every setting in OpsLog is **per-profile**: station identity, CAT, lookup, QSL
defaults, awards display, antennas, and which logbook to use. Create one profile defaults, awards display, antennas, and which logbook to use. Create one profile
per callsign / station / event. per callsign / station / event.
Switch or manage profiles in Settings. The active profile decides which logbook The **callsign in the top bar is the station switcher** — click it and pick a
your QSOs go to. profile. Managing profiles is at the bottom of that list (and in Settings).
## Config vs. logbook — two different stores ## Two separate databases
Since 0.21.0 the settings and the QSOs live in **separate files**:
| Store | What | Where | | Store | What | Where |
|-------|------|-------| |-------|------|-------|
| **Config** | settings, profiles, rigs/antennas, cluster nodes, lookup cache, award lists, QSL templates | always the **local SQLite** file under `data/` | | **Settings database** (`settings.db`, or `opslog.db` on older installs) | settings, profiles, rigs/antennas, cluster nodes, lookup cache, award lists, QSL templates | always the **local SQLite** file under `data/` |
| **Logbook** | your QSOs | where the active profile points — local SQLite **or** shared MySQL | | **Logbook** (`logbook.db`) | your QSOs | where the active profile points — a local SQLite file **or** shared MySQL |
Keeping config local means the UI is instant even when the logbook is a far-away Keeping the settings local means the UI is instant even when the logbook is a
MySQL server. far-away MySQL server. Settings → Database shows the two as separate sections,
with an *Open folder* shortcut; a logbook file can be **renamed or relocated**
(its QSOs move with it), and the status bar names the **logbook** file — that
is the one to back up.
A profile can point at its **own logbook file** — ideal for a visiting
operator, whose contacts stay out of your log without touching your settings.
**Portable**: paths inside the OpsLog folder are stored relative to it, so
moving the whole folder to another drive or PC loses nothing.
## Shared MySQL logbook (multi-operator) ## Shared MySQL logbook (multi-operator)
@@ -25,11 +36,18 @@ Point a profile's logbook at a **MySQL** database so several operators run **one
log** — e.g. a multi-op special-event call. Configure the host / database / log** — e.g. a multi-op special-event call. Configure the host / database /
credentials in the profile's database settings. credentials in the profile's database settings.
This is also what powers [[Multi-Operator Live Status]] (each instance heartbeats - The database and tables are converted to **utf8mb4** on connect, so Cyrillic
its activity into a `live_status` table). or Polish characters from a QRZ lookup store correctly even when the hosting
panel created the database as latin1.
- An FT8/MSHV QSO that cannot reach a laggy shared database is **parked and
re-logged automatically** once it recovers — no lost QSOs.
- Switching to a profile whose MySQL is unreachable shows a clear warning
instead of silently staying on the previous logbook.
This is also what powers [[Multi-Operator Live Status]].
## Backups ## Backups
Optional **database + ADIF backup at shutdown** (Settings → Backup). Regardless, Optional **database + ADIF backup at shutdown** (Settings → Backup). Regardless,
copying the `data/` folder backs up your config and local logbook. See copying the `data/` folder backs up your settings and local logbook. See
[[Settings and Data]]. [[Settings and Data]].
+22 -8
View File
@@ -4,13 +4,23 @@
The main log table. Double-click a row to edit; right-click for bulk actions The main log table. Double-click a row to edit; right-click for bulk actions
(see [[Logging Basics]]). Columns are configurable — click **Columns** to choose (see [[Logging Basics]]). Columns are configurable — click **Columns** to choose
which are visible; your selection is saved (per profile). which are visible; widths, order and hidden columns are saved (per profile),
with a separate layout for the narrower Main-tab pane.
- The selected-row count is always visible at the top, and a **Select all /
Unselect all** button takes every displayed row.
- QSL and upload status columns are coloured: **Y** green, **N** red, **R**
blue.
- A **Distance (km)** column (also in Worked-before) is computed from the QSO's
own locator pair.
- There are **no per-column header filters** — they only ever searched the rows
on screen, which made a QSO further back in the log look missing. Use the
advanced filter, which queries the whole logbook.
### Award columns ### Award columns
Each defined award can show a column with the reference the QSO counts for. These Each defined award can show a column with the reference the QSO counts for.
columns are **hidden by default** and opt-in from the Columns picker — turn on These columns are **hidden by default** and opt-in from the Columns picker.
only the awards you're chasing. Your choice persists across restarts.
## Advanced filter builder ## Advanced filter builder
@@ -19,15 +29,19 @@ Click the filter button to open the **QSO filter builder** (Log4OM-style):
- Add one or more **conditions**: *field · operator · value*. - Add one or more **conditions**: *field · operator · value*.
- Operators: equals, not-equal, contains, starts/ends with, greater/less - Operators: equals, not-equal, contains, starts/ends with, greater/less
(or equal), is empty, is not empty. (or equal), is empty, is not empty.
- Fields include every confirmation **status and date** (sent / received,
before / after) for paper QSL, LoTW, eQSL, Club Log, HRDLog and QRZ.com.
Field names stay in English — they are ADIF names, a standard vocabulary.
- Join conditions with **ALL (AND)** or **ANY (OR)**. - Join conditions with **ALL (AND)** or **ANY (OR)**.
- Save named **presets** (they travel with the `data/` folder). - Save named **presets** (they travel with the `data/` folder).
- The field dropdown is sorted alphabetically to find things fast.
The filtered view can be exported: **selected rows** or the **whole filtered A filter shows **every** match (the on-screen row limit only applies to the
view** (no row limit) → ADIF or Cabrillo. unfiltered log; safety cap 10 000). The filtered view can be exported:
**selected rows** or the **whole filtered view** → ADIF or Cabrillo.
## Worked-before matrix ## Worked-before matrix
As you type a callsign, the worked-before grid shows prior contacts with that As you type a callsign, the worked-before grid shows prior contacts with that
**entity**, arranged by band/mode slot, so you instantly see what's new (new **entity**, arranged by band/mode slot, so you instantly see what's new (new
DXCC / new band / new slot) versus already worked. DXCC / new band / new slot) versus already worked. Its right-click menu offers
the same bulk edit and export as the main grid.
+32 -22
View File
@@ -4,48 +4,58 @@
A `data/` folder is created **next to `OpsLog.exe`**: A `data/` folder is created **next to `OpsLog.exe`**:
- **Config** (SQLite): settings, profiles, rigs/antennas, cluster nodes, lookup - **Settings database** (SQLite): settings, profiles, rigs/antennas, cluster
cache, award lists, QSL templates. Always local, so the UI is instant. nodes, lookup cache, award lists, QSL templates. Always local.
- **Local logbook** (SQLite): QSOs, when the profile logs locally. - **Logbook** (`logbook.db`, SQLite): QSOs, when the profile logs locally.
- `data/qsl/templates/…` — QSL card templates and their photos. - `data/qsl/templates/…` — QSL card templates and their photos.
- `data/qsl/outbox/…` — sent QSL card JPEGs. - `data/qsl/outbox/…` — sent QSL card JPEGs.
- Recordings folder (configurable) — per-QSO WAV/MP3. - Recordings folder (configurable) — per-QSO WAV/MP3.
- Backups. - Backups, diagnostic logs.
Back up your setup by copying `data/` (and keep it with the exe). See Paths are stored **relative to the folder**, so the whole OpsLog directory can
move to another drive or PC. Back up your setup by copying `data/`. See
[[Profiles and Databases]] for local-vs-MySQL logbooks. [[Profiles and Databases]] for local-vs-MySQL logbooks.
## Settings map ## Settings map
| Section | What | | Section | What |
|---------|------| |---------|------|
| **General** | language, theme, Main-view panes, live-status publish | | **General** | language, theme, date format (US / FR / Standard), Main-view panes, digital-modes grouping, Super Check Partial, ClubLog exceptions & Most Wanted |
| **Station** | your callsign, grid, name, address (my-station ADIF) | | **Station** | your callsign, grid, name, address (my-station ADIF) — hand-corrected CQ/ITU zones stay corrected |
| **CAT** | radio backend + connection ([[CAT Control]]) | | **CAT** | radio backend + connection, CAT sharing (rigctld), protocol trace ([[CAT Control]]) |
| **Lookup** | QRZ.com / HamQTH credentials | | **Lookup** | QRZ.com / HamQTH credentials |
| **FlexRadio** | per-band RX/TX antennas | | **Audio** | DVK / recorder devices, PTT, gains and levels, RX monitor |
| **Audio** | DVK / recorder devices, PTT, gains, RX monitor | | **CW Keyer** | engine (WinKeyer / Serial / Icom / Flex CWX / Yaesu / TCI), macros, ESM |
| **CW Keyer** | WinKeyer / Icom / TCI engine + macros | | **External services** | LoTW (TQSL), QRZ, eQSL, ClubLog, HRDLog, POTA, Cloudlog/Wavelog |
| **External services** | LoTW (TQSL), QRZ, eQSL, ClubLog, HRDLog, POTA | | **Email** | SMTP for eQSL-by-email and *Send log to F4BPO* |
| **Email** | SMTP for eQSL-by-email | | **Amplifier / Tuner Genius / Antenna Genius / Rotator / Antenna** | station hardware ([[Amplifiers and Switches]] · [[Maps and Antennas]]) |
| **PowerGenius / Antenna Genius / Rotator / Ultrabeam** | station hardware | | **Database** | settings DB + this profile's logbook, rename/relocate |
| **Backup** | database + ADIF backup at shutdown | | **Backup** | database + ADIF backup at shutdown |
## Appearance & language ## Appearance & language
Four themes (Warm light, Warm dark, Graphite dark, High contrast) plus **Auto** Four themes (Warm light, Warm dark, Graphite dark, High contrast) plus **Auto**
(follows the OS). Full **English / French** UI with a first-run flag chooser and (follows the OS); fresh installs start dark. Full **English / French** UI with
a switcher in Settings → General. a first-run flag chooser and a switcher in Settings → General. **Ctrl + wheel**
zooms the whole UI (remembered; Ctrl+0 resets). The window title bar is drawn
by OpsLog — drag the top bar to move, double-click to maximise — and the window
reopens on the screen it was closed on.
## Integrations (outbound UDP) ## UDP integrations
Push the current frequency to **PstRotator**, radio info in **N1MM `RadioInfo`** - **Inbound**: WSJT-X / MSHV / JTDX QSO logging and status (unicast or
format, or an **ADIF record on each logged QSO** — so external tools stay in sync. multicast), and DXHunter remote-call packets (fills the callsign, tunes the
rig when the packet carries `<FREQ>`/`<MODE>`).
- **Outbound**: the current frequency to **PstRotator**, radio info in **N1MM
`RadioInfo`** format, or an **ADIF record on each logged QSO**.
## Other ## Other
- **Autostart** external programs (WSJT-X, JTAlert, rotator control…) at launch, - **Autostart** external programs (WSJT-X, JTAlert, rotator control…) at
skipping any already running. launch, skipping any already running.
- **Update check** at startup (toggleable). - **Update check** at startup, when opening Help → About, and every 5 minutes.
A "What's new" summary appears on the first launch after each update.
- **Help → Send log to F4BPO** (shown when SMTP is configured) e-mails the
diagnostic logs straight to the developer.
- **Anonymous usage telemetry** — a once-a-day heartbeat (random install ID + - **Anonymous usage telemetry** — a once-a-day heartbeat (random install ID +
version + OS; no callsign or QSO data). Opt out in Preferences. version + OS; no callsign or QSO data). Opt out in Preferences.
+50 -30
View File
@@ -2,57 +2,77 @@
## Where's the log file? ## Where's the log file?
OpsLog writes a diagnostic log. The path is shown in Settings (and can be opened OpsLog writes a diagnostic log. The path is shown in Settings (and can be
from there). When reporting a problem, include the relevant lines — they're opened from there). When reporting a problem, include the relevant lines —
prefixed by subsystem (`Flex:`, `icom net:`, `icom scope`, `antgenius:`, they're prefixed by subsystem (`cat:`, `Flex:`, `icom net:`, `antgenius:`,
`cluster:`, `qslmgr:log`, `audio:`…). `cluster:`, `qslmgr:log`, `audio:`…). With an SMTP server configured, **Help →
Send log to F4BPO** e-mails the logs straight to the developer.
A crash no longer leaves a blank window: the error is shown on screen,
selectable, with a copy button.
## CAT ## CAT
- **Radio not connecting** — check the backend fields (COM port + baud for USB; - **Radio not connecting** — check the backend fields (COM port + baud for USB;
IP/credentials for network). OpsLog connects non-blocking, so it won't freeze — IP/credentials for network). The CAT log line carries the error explaining a
watch the CAT status. disconnection.
- **Icom: no CAT at all over USB** — the CI-V address must match the radio; pick - **"Serial port busy" in the log** — another program holds the COM port
your **model** from the dropdown (it sets the address) and set *CI-V USB Echo (WSJT-X, MSHV, another logger configured for direct CAT). Only one program
Back* **OFF** on the rig. can own the port: let OpsLog hold it and point the others at **Hamlib NET
- **Icom scope blank on an IC-7300** — this was a bug where the scope decoder was rigctl 127.0.0.1:4532** (Settings → CAT → Share CAT). Two masters on one
chosen from the configured CI-V address; it now uses the model detected from the CI-V bus also cause garbled frames and erratic behaviour.
CI-V ID, so a single-scope rig run at address `0x98` decodes correctly. Update - **The radio transmits as soon as OpsLog connects** — your interface reads DTR
to the latest build. or RTS as PTT. Xiegu drops the lines automatically; on Yaesu and Kenwood
- **Icom attenuator button does nothing** — the dB steps are model-specific; make enable the *lower DTR/RTS on connect* option. (It is off by default because
sure the right model is selected (IC-7300 = 20 dB, IC-7610 = 6/12/18 dB). other interfaces stop transmitting with the lines low.)
- **WSJT-X won't key a Xiegu G90 through OmniRig** — a G90 ignores the CI-V PTT
command. Use the native Xiegu backend (which keys RTS/DTR) + rigctl sharing,
or set the PTT line inside OmniRig. See [[Kenwood and Xiegu]].
- **OmniRig frequency frozen / wrong VFO** — set **VFO to read** (A or B) in
the OmniRig backend settings; some rig files declare the wrong VFO.
- **"OmniRig not found" with OmniRig running** — one of the two runs as
administrator and the other doesn't. Start both the same way.
- **Icom: no CAT at all over USB** — the CI-V address must match the radio;
pick your **model** from the dropdown (it sets the address) and set *CI-V USB
Echo Back* **OFF** on the rig.
- **A radio that answers oddly** — enable the **protocol trace** (Settings →
CAT): every frame to and from the rig lands in the log as hex, which turns a
guess into a diagnosis.
## Remote Icom ## Remote Icom
- **Login rejected** → wrong Network User1 name/password. - **Login rejected** → wrong Network User1 name/password.
- **Can't bind local port** → the Icom **Remote Utility is still running** - **Can't bind local port** → the Icom **Remote Utility is still running**
close it. close it.
- **Link drops with the scope on** → receive-side retransmit should hold it; if - **Frequency frozen after another program took the CI-V session** — OpsLog
not, check network quality and the `icom net:` log lines. See reconnects on its own; if it doesn't, check the `icom net:` log lines. See
[[Remote Icom over the Internet]]. [[Remote Icom over the Internet]].
## FlexRadio ## FlexRadio
- **A control reads 0 / stale at startup** — some SmartSDR status fields use short - **A control reads 0 / stale at startup** — some SmartSDR status fields use
names (e.g. the TX filter is reported as `lo`/`hi`, not `filter_low/high`). If a short names. If a value never populates, capture the `Flex: … status` log
value never populates, capture the `Flex: … status` log line and report it. line and report it.
- **Antenna wrong band on a spot click** — fixed: `SetFrequency`/`SetMode` update - **SmartSDR CAT keeps disconnecting while OpsLog is open** — fixed: OpsLog now
the slice cache immediately so the panel and the Ultrabeam follow don't chase a binds only to the actual SmartSDR/Maestro GUI client. Update.
stale frequency. - **"Interlock is preventing transmission" with a SteppIR** — see the tunable
range setting in [[Maps and Antennas]].
## Multi-op live status PHP ## Multi-op live status PHP
`Connection refused` from the PHP page is almost always MySQL `bind-address = `Connection refused` from the PHP page is almost always MySQL `bind-address =
127.0.0.1` or a firewall/grant issue — see 127.0.0.1` or a firewall/grant issue — see [[Multi-Operator Live Status]].
[[Multi-Operator Live Status]].
## DVK / PTT ## DVK / PTT
- **Test PTT does nothing on CAT/OmniRig** — the key is held ~1.5 s so the async - **Test PTT does nothing on CAT/OmniRig** — the key is held ~1.5 s so the
OmniRig write is actually sent; if it still doesn't key, the rig's OmniRig async OmniRig write is actually sent; if it still doesn't key, the rig's
profile may not expose CAT TX keying — use RTS/DTR or VOX. OmniRig profile may not expose CAT TX keying — use RTS/DTR or VOX.
- **A voice message transmits almost nothing** — raise the message level
(Settings → Audio) and check the rig's rear-input setting (FTDX10: SSB MOD
SOURCE = REAR).
## Still stuck? ## Still stuck?
Open an issue with: what you did, what happened, your radio/model, and the Open an issue (or *Send log to F4BPO*) with: what you did, what happened, your
relevant log lines. radio/model, and the relevant log lines.
+47
View File
@@ -0,0 +1,47 @@
# Yaesu (native CAT)
A native backend talks to an **FTDX10 / FTDX101** (and close relatives) directly
over the rig's USB/serial port — no OmniRig. Pick **Yaesu (USB)** in
Settings → CAT: COM port + baud (FTDX10/101 default 38400).
Frequency, mode, VFO A/B (MAIN/SUB on the 101), split and PTT are covered, and
OpsLog **follows the VFO you are actually on** — the displayed frequency, the
logged frequency and the VFO a cluster spot tunes. Split is worked out from
which VFO transmits, so simplex on the SUB receiver is not shown as split.
## The Yaesu console
With the native backend connected, a console tab (and a Main-view pane) gives:
- **S / PO / SWR meters** — power in watts on a curve measured against the rig's
own display, SWR as the ratio itself, with peak-hold so they stay readable
between CW words or SSB syllables.
- **Band and mode buttons** — CW, RTTY, DIGI and PSK show their sideband; click
the active button again to switch it.
- AF / RF / squelch, AGC, IPOAMP1AMP2, 6/12/18 dB attenuator, NB, DNR, narrow
filter, power, mic gain, VOX, split and **ATU tune**.
- **SPLIT** places the transmit VFO up 1 kHz on CW/data, up 5 kHz on phone.
- In CW the mic gain and VOX give way to a **CW card**: keyer speed, break-in
and ZIN (zero-in).
Sliders only respond to the mouse wheel after you click them, so scrolling the
panel can't change the transmit power by accident.
## CW with the rig's own keyer
A CW engine sends your macros with the radio's internal keyer (CAT `KY`) — no
WinKeyer, no second cable. Pick **Yaesu (rig keyer)** in Settings → CW keyer.
- Works on the **FTDX101 / FT-991A / FT-710** family.
- An **FTDX10 does not accept it** (`KY` is refused, `DAKY` doesn't help) —
OpsLog says so and points to the **serial DTR keyer** on the rig's second COM
port (the *Serial port* CW engine), which works. See [[Audio and Keyers]].
- CW speed is one setting everywhere: the console slider, the CW panel and the
rig's front panel stay in agreement.
## Notes
- A Yaesu that is switched off is reported as such (opening the port is not
"connected").
- If your interface goes into transmit the moment OpsLog connects, enable the
**lower DTR/RTS on connect** option — see [[CAT Control]].
+2
View File
@@ -16,6 +16,8 @@
- [[FlexRadio]] - [[FlexRadio]]
- [[Icom]] - [[Icom]]
- [[Remote Icom over the Internet]] - [[Remote Icom over the Internet]]
- [[Yaesu]]
- [[Kenwood and Xiegu]]
**Operating** **Operating**
- [[DX Cluster and Spots]] - [[DX Cluster and Spots]]