chore: release v0.25.9
This commit is contained in:
@@ -94,7 +94,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||
import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
||||
import { GetMatrixColors, GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App';
|
||||
import { GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App';
|
||||
import { applyMatrixColors } from '@/lib/matrixColors';
|
||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||
import { NetControlPanel } from '@/components/NetControlPanel';
|
||||
@@ -2018,6 +2018,10 @@ export default function App() {
|
||||
// settings dialog closes, which is the only place it changes.
|
||||
const [rowColors, setRowColors] = useState<any>(null);
|
||||
useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]);
|
||||
// Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
|
||||
// row colours: the settings dialog is the only place they change.
|
||||
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
|
||||
useEffect(() => { GetRotorPresets().then((p) => setRotorPresets((p ?? []) as any)).catch(() => {}); }, [showSettings]);
|
||||
// Band/mode matrix palette overrides (Settings → Appearance). Stamped onto
|
||||
// <html> rather than held in state: the matrix reads CSS custom properties, so
|
||||
// nothing re-renders and no component has to be told about the colours. Same
|
||||
@@ -6310,8 +6314,10 @@ export default function App() {
|
||||
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
|
||||
rotator is configured or a DX bearing exists. */}
|
||||
{showRotor && (rotatorHeading.enabled || dxPath) && (
|
||||
<div className="w-[186px] shrink-0 min-h-0">
|
||||
<div className="w-[320px] shrink-0 min-h-0">
|
||||
<RotorCompass
|
||||
presets={rotorPresets}
|
||||
onStop={() => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
|
||||
bearing={dxPath?.bearingShort ?? null}
|
||||
headings={beamHeadings}
|
||||
boomHeading={boomHeading}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
||||
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
// BandMap — vertical spectrum panel inspired by Log4OM.
|
||||
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
||||
@@ -217,7 +218,37 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot:
|
||||
// Pixels-per-kHz at each zoom step. Base 8 px/kHz means a stacked spot
|
||||
// every 2.75 kHz fits without anti-overlap kicking in — comfortable for
|
||||
// most bands. Higher levels are for fine inspection of crowded sub-bands.
|
||||
const PX_PER_KHZ = [8, 16, 32, 64, 128, 256];
|
||||
// Zoom steps, px per kHz. 2 and 4 sit below the old floor of 8 so a whole band
|
||||
// fits without FIT taking the scale away from you: 20 m end to end is 700 px at
|
||||
// 2 px/kHz, which scrolls in one screen on most displays.
|
||||
const PX_PER_KHZ = [2, 4, 8, 16, 32, 64, 128, 256];
|
||||
const DEFAULT_ZOOM_IDX = PX_PER_KHZ.indexOf(32);
|
||||
|
||||
// Zoom is remembered PER BAND, in ONE json key rather than one key per band:
|
||||
// lib/uiPref keeps an explicit list of the preferences that travel with data/,
|
||||
// and thirteen entries there to say the same thing would be thirteen chances to
|
||||
// forget one.
|
||||
const ZOOM_KEY = 'opslog.bandMapZoom';
|
||||
|
||||
function readZoomMap(): Record<string, number> {
|
||||
try {
|
||||
const m = JSON.parse(localStorage.getItem(ZOOM_KEY) || '{}');
|
||||
return m && typeof m === 'object' ? m : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readZoom(band: string): number {
|
||||
const n = readZoomMap()[band];
|
||||
return Number.isInteger(n) && n >= 0 && n < PX_PER_KHZ.length ? n : DEFAULT_ZOOM_IDX;
|
||||
}
|
||||
|
||||
function writeZoom(band: string, idx: number): void {
|
||||
const m = readZoomMap();
|
||||
m[band] = idx;
|
||||
writeUiPref(ZOOM_KEY, JSON.stringify(m));
|
||||
}
|
||||
const SCALE_W = 56;
|
||||
const PILL_H = 22; // px — height of each callsign pill
|
||||
const PILL_GAP = 32; // px between scale border and first pill (room for leader)
|
||||
@@ -252,7 +283,22 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
||||
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
|
||||
const range = BAND_RANGES[band];
|
||||
const segments = SEGMENT_COLORS[band] ?? [];
|
||||
const [zoomIdx, setZoomIdx] = useState(2); // default 32 px/kHz
|
||||
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
|
||||
// The docked map follows the rig, so a band change must bring up THAT band's
|
||||
// remembered zoom.
|
||||
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
|
||||
// Stored from the interaction, not from an effect on zoomIdx: an effect would
|
||||
// also fire on the band change above, and in the same commit it would still be
|
||||
// holding the PREVIOUS band's index — writing it over the new band's. Doing it
|
||||
// where the operator actually turns the wheel keeps the two apart. (Running
|
||||
// twice under StrictMode is harmless: the same value is stored.)
|
||||
const changeZoom = useCallback((delta: number) => {
|
||||
setZoomIdx((z) => {
|
||||
const n = Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + delta));
|
||||
if (n !== z) writeZoom(band, n);
|
||||
return n;
|
||||
});
|
||||
}, [band]);
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerH, setContainerH] = useState(400);
|
||||
|
||||
@@ -421,7 +467,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
||||
if (!range) return;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
setZoomIdx((z) => Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + (e.deltaY > 0 ? -1 : 1))));
|
||||
changeZoom(e.deltaY > 0 ? -1 : 1);
|
||||
}
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
@@ -476,8 +522,10 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
||||
|
||||
// Tick step (where small marks land) and label step (where the kHz
|
||||
// number is printed) are decoupled so the scale shows ~10-20 numeric
|
||||
// labels per viewport regardless of zoom — at base 8 px/kHz we want
|
||||
// labels every 25 kHz, not every 250.
|
||||
// labels per viewport regardless of zoom — at 8 px/kHz we want labels
|
||||
// every 25 kHz, not every 250. The bare values below are the floor, and
|
||||
// they are what the 2 px/kHz step lands on: a whole band in one screen
|
||||
// wants a number every 100 kHz, not every 25.
|
||||
let tickStep = 50;
|
||||
let labelStep = 100;
|
||||
if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; }
|
||||
@@ -502,14 +550,17 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
||||
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
|
||||
<span className="flex-1 min-w-0 truncate">{t('bmp.map')} · {band}</span>
|
||||
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={fitToBand || zoomIdx === 0}
|
||||
{/* The band alone. "Map · 20M" spent a third of a narrow header saying
|
||||
what the panel obviously is, and with four band maps side by side it
|
||||
was four times the same word. */}
|
||||
<span className="flex-1 min-w-0 truncate">{band}</span>
|
||||
<button type="button" onClick={() => changeZoom(-1)} disabled={fitToBand || zoomIdx === 0}
|
||||
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||
title={t('bmp.zoomOut')}>
|
||||
<Minus className="size-3" />
|
||||
</button>
|
||||
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
|
||||
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
|
||||
<button type="button" onClick={() => changeZoom(1)} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
|
||||
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||
title={t('bmp.zoomIn')}>
|
||||
<Plus className="size-3" />
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
|
||||
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
|
||||
// to turn the antenna there.
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
|
||||
import { feature } from 'topojson-client';
|
||||
import landTopo from 'world-atlas/land-110m.json';
|
||||
import { Compass, X } from 'lucide-react';
|
||||
import { Compass, X, Play, Square } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// Decode the coastline outline once (≈110 m simplified land polygons).
|
||||
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
|
||||
@@ -29,6 +30,11 @@ interface Props {
|
||||
onSelectRotor?: (i: number) => void; // switch the active rotor
|
||||
onGoto?: (az: number) => void; // click-to-turn
|
||||
onClose?: () => void;
|
||||
// Quick-turn buttons and the azimuth box, shown only where the caller wants
|
||||
// them: Station Control draws its own GoTo/Stop around this compass, and two
|
||||
// sets of the same controls side by side would be nothing but confusing.
|
||||
presets?: { label: string; azimuth: number }[];
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
const SIZE = 168;
|
||||
@@ -41,7 +47,43 @@ function pt(az: number, radius: number): [number, number] {
|
||||
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
||||
}
|
||||
|
||||
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose }: Props) {
|
||||
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose, presets, onStop }: Props) {
|
||||
const { t } = useI18n();
|
||||
// Raw text, not a number: binding the input to a normalised value makes
|
||||
// Backspace fight the operator on the way from "230" to "23". It is parsed
|
||||
// when it is sent, and only then.
|
||||
const [azText, setAzText] = useState('');
|
||||
const showControls = !!(presets || onStop);
|
||||
|
||||
// Which preset was just pressed, so it can light up for a moment.
|
||||
//
|
||||
// A rotor takes seconds to start moving and the needle barely twitches at
|
||||
// first, so without this the only answer to "did that register?" is to press
|
||||
// it again — which is how an antenna ends up ordered somewhere twice. The
|
||||
// acknowledgement has to come from the button itself, at once.
|
||||
const [flashIdx, setFlashIdx] = useState<number | null>(null);
|
||||
const flashTimer = useRef<number | undefined>(undefined);
|
||||
useEffect(() => () => window.clearTimeout(flashTimer.current), []);
|
||||
const pressPreset = (i: number, az: number) => {
|
||||
if (!onGoto) return;
|
||||
setFlashIdx(i);
|
||||
window.clearTimeout(flashTimer.current);
|
||||
flashTimer.current = window.setTimeout(() => setFlashIdx(null), 450);
|
||||
onGoto(az);
|
||||
};
|
||||
|
||||
// 0-359 and nothing else. 360 is refused rather than folded to 0 — it is
|
||||
// almost always a typo for 36 or 306, and a rotor swinging through north on a
|
||||
// slip of the finger is worth one rejected keypress.
|
||||
const sendAz = () => {
|
||||
const s = azText.trim();
|
||||
if (s === '' || !onGoto) return;
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 359) return;
|
||||
onGoto(n);
|
||||
setAzText('');
|
||||
};
|
||||
|
||||
const cardinals = useMemo(
|
||||
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
||||
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
||||
@@ -72,6 +114,35 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
||||
|
||||
const headLabel = headings.length ? headings[0] : null;
|
||||
|
||||
// Short and long path to the DX, in figures.
|
||||
//
|
||||
// The bezel already carries the short path as a red marker, but a marker is a
|
||||
// direction, not a number — the same pair sits in the status bar at 10px and
|
||||
// operators reported not being able to read it. It lives here because it
|
||||
// belongs to the compass: every place that draws one gets the readout, instead
|
||||
// of each caller inventing its own. Clickable when the caller can turn, like
|
||||
// the status bar's. Built as a value because it is placed in one of two
|
||||
// columns depending on whether the controls are shown.
|
||||
const pathReadout = (
|
||||
<div className="flex gap-1.5 mt-2 font-mono w-full">
|
||||
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
|
||||
<button key={lbl} type="button" disabled={az == null || !onGoto}
|
||||
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
|
||||
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
|
||||
className={
|
||||
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors active:scale-95 ' +
|
||||
(az == null
|
||||
? 'border-border text-muted-foreground/50 cursor-not-allowed'
|
||||
: onGoto
|
||||
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
|
||||
: 'border-border text-muted-foreground cursor-default')
|
||||
}>
|
||||
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
|
||||
{/* Header — matches the WinKeyer / Voice keyer panels. */}
|
||||
@@ -128,9 +199,13 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dial on the left, controls on the right. The controls column is sized to
|
||||
fit WITHIN the dial's height: the widget sits in a row whose height is
|
||||
set by the entry strip, so it may grow sideways but never downwards. */}
|
||||
<div className="flex items-start gap-2 p-2 min-h-0">
|
||||
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a
|
||||
sibling of the <svg> landed beside it. */}
|
||||
<div className="flex flex-col items-center justify-center p-2 min-h-0">
|
||||
<div className="flex flex-col items-center justify-center min-h-0 shrink-0">
|
||||
<svg
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
|
||||
@@ -191,30 +266,85 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
||||
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
||||
</svg>
|
||||
|
||||
{/* Short and long path to the DX, in figures.
|
||||
The bezel already carries the short path as a red marker, but a
|
||||
marker is a direction, not a number — the same pair sits in the
|
||||
status bar at 10px and operators reported not being able to read it.
|
||||
Here because it belongs to the compass: every place that draws one
|
||||
gets the readout, instead of each caller inventing its own.
|
||||
Clickable when the caller can turn, like the status bar's. */}
|
||||
<div className="flex gap-1.5 mt-2 font-mono w-full">
|
||||
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
|
||||
<button key={lbl} type="button" disabled={az == null || !onGoto}
|
||||
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
|
||||
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
|
||||
className={
|
||||
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors ' +
|
||||
(az == null
|
||||
? 'border-border text-muted-foreground/50 cursor-not-allowed'
|
||||
: onGoto
|
||||
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
|
||||
: 'border-border text-muted-foreground cursor-default')
|
||||
}>
|
||||
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
|
||||
{/* With the controls column present the readout goes at the FOOT OF IT
|
||||
instead: the dial sets the widget's height, the controls are shorter
|
||||
than the dial, and that leftover space is exactly the right size for
|
||||
the pair. Under the dial it would push the whole widget taller. */}
|
||||
{!showControls && pathReadout}
|
||||
</div>
|
||||
|
||||
{/* Quick turns + free azimuth + Stop. */}
|
||||
{showControls && (
|
||||
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||
{/* Two columns so six regions fit beside the dial rather than under
|
||||
it. An operator with fewer keeps the same compact block. */}
|
||||
{!!presets?.length && (
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{presets.map((p, i) => (
|
||||
<button
|
||||
key={`${p.label}-${i}`}
|
||||
type="button"
|
||||
disabled={!onGoto}
|
||||
onClick={() => pressPreset(i, p.azimuth)}
|
||||
title={`${p.label} — ${p.azimuth}°`}
|
||||
className={cn(
|
||||
'rounded-md border py-1 text-xs font-semibold truncate transition-all duration-150 active:scale-95',
|
||||
flashIdx === i
|
||||
// Lit, and showing the azimuth it just sent: the label alone
|
||||
// would only say the press landed, not what was ordered.
|
||||
? 'border-success bg-success text-success-foreground scale-95'
|
||||
: 'border-border bg-muted/40',
|
||||
onGoto && flashIdx !== i ? 'hover:bg-muted' : '',
|
||||
!onGoto ? 'opacity-50 cursor-not-allowed' : '',
|
||||
)}
|
||||
>
|
||||
{flashIdx === i ? `${p.azimuth}°` : p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Free azimuth: Enter sends, so the whole thing is type-three-digits
|
||||
-and-go without reaching for the mouse. */}
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={azText}
|
||||
onChange={(e) => setAzText(e.target.value.replace(/[^0-9]/g, '').slice(0, 3))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendAz(); } }}
|
||||
placeholder={t('rotor.azPh')}
|
||||
title={t('rotor.azTitle')}
|
||||
disabled={!onGoto}
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-mono tabular-nums text-center disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={sendAz}
|
||||
disabled={!onGoto || azText.trim() === ''}
|
||||
title={t('rotor.go')}
|
||||
className="flex items-center gap-1 rounded-md border border-success/60 bg-success-muted px-2 py-1 text-xs font-bold text-success-muted-foreground transition-transform hover:bg-success/25 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Play className="size-3" /> {t('rotor.go')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{onStop && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
title={t('rotor.stop')}
|
||||
className="flex items-center justify-center gap-1.5 rounded-md border border-destructive/60 bg-destructive/15 py-1 text-xs font-bold text-destructive hover:bg-destructive/25"
|
||||
>
|
||||
<Square className="size-3 fill-current" /> {t('rotor.stop')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* mt-auto: the readout sits at the FOOT of the column, level with the
|
||||
bottom of the dial, instead of floating under the Stop button. */}
|
||||
<div className="mt-auto">{pathReadout}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
|
||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||
@@ -1399,16 +1400,25 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [bandDraft, setBandDraft] = useState('');
|
||||
const [modeDraft, setModeDraft] = useState('');
|
||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
|
||||
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
|
||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
|
||||
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
|
||||
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
|
||||
});
|
||||
// While true, the next key press is captured as the PTT hotkey.
|
||||
const [capturingPtt, setCapturingPtt] = useState(false);
|
||||
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
|
||||
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
|
||||
// Whether the presets have actually been READ back yet.
|
||||
//
|
||||
// An empty list is a legitimate answer — an operator may want no quick-turn
|
||||
// buttons at all — so the backend stores it as one. That makes "not loaded
|
||||
// yet" and "deliberately none" the same value, and a Save landing in the first
|
||||
// state wiped the buttons for good. Saving is gated on having read them, so
|
||||
// the only empty list that can ever reach the store is one the operator made.
|
||||
const [rotorPresetsLoaded, setRotorPresetsLoaded] = useState(false);
|
||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
@@ -1825,6 +1835,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
await reloadClusterServers();
|
||||
setCatCfg(c);
|
||||
setRotors((r ?? []) as any);
|
||||
// Loaded HERE, in the loader that runs on mount — not only in the
|
||||
// event-driven one below. Missing from this one, the state stayed empty
|
||||
// on a normal open and Save then wrote an empty list over the operator's
|
||||
// buttons. See rotorPresetsLoaded for the belt to this brace.
|
||||
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
|
||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||
@@ -1868,6 +1883,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
try { setLookup(await GetLookupSettings() as any); } catch {}
|
||||
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
||||
try { setRotors(((await GetRotators()) ?? []) as any); } catch {}
|
||||
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
|
||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||
@@ -2062,6 +2078,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
await SaveLookupSettings(lookup as any);
|
||||
await SaveCATSettings(catCfg as any);
|
||||
await SaveRotators(rotors as any);
|
||||
// Only once they have been read back — see rotorPresetsLoaded.
|
||||
if (rotorPresetsLoaded) await SaveRotorPresets(rotorPresets as any);
|
||||
await SaveUltrabeamSettings(ultrabeam as any);
|
||||
await SaveAntGeniusSettings(antgenius as any);
|
||||
await SaveTunerGeniusSettings(tunergenius as any);
|
||||
@@ -2862,6 +2880,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<span className="text-xs text-muted-foreground">{t('cat.flexDecodeSecsHint')}</span>
|
||||
</div>
|
||||
)}
|
||||
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox className="mt-0.5" checked={!!catCfg.flex_dvk_dax} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_dvk_dax: !!c }))} />
|
||||
<span>{t('cat.flexDvkDax')} <span className="text-xs text-muted-foreground">{t('cat.flexDvkDaxHint')}</span></span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'xiegu' && (
|
||||
@@ -3142,6 +3164,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Transverter offset. OUTSIDE the per-backend blocks on purpose: a
|
||||
transverter hangs off the IF of whatever radio you own, and the
|
||||
offset is applied in the CAT manager, above every backend. Tucked
|
||||
under the OmniRig/Icom section it would have been invisible to the
|
||||
Yaesu, Kenwood, Xiegu, Flex and TCI operators who need it just as
|
||||
much. */}
|
||||
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox className="mt-0.5" checked={!!catCfg.offset_on}
|
||||
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, offset_on: !!c }))} />
|
||||
<span>{t('cat.offsetOn')} <span className="text-xs text-muted-foreground">{t('cat.offsetHint')}</span></span>
|
||||
</label>
|
||||
{catCfg.offset_on && (
|
||||
<div className="col-span-2 flex items-center gap-2 pl-6">
|
||||
<Label className="text-sm">{t('cat.offsetMhz')}</Label>
|
||||
<Input
|
||||
type="number" step="0.000001" className="w-40"
|
||||
value={(catCfg.offset_hz ?? 0) / 1e6}
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, offset_hz: Math.round((parseFloat(e.target.value) || 0) * 1e6) }))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('cat.offsetExample')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>{t('cat.digitalDefault')}</Label>
|
||||
<Select
|
||||
@@ -4135,6 +4179,41 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick-turn buttons for the rotor widget. Their azimuths start out
|
||||
computed from the station square, so they are right for THIS QTH
|
||||
rather than copied from someone else's. */}
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<div className="text-sm font-semibold">{t('rot.presets')}</div>
|
||||
<p className="text-xs text-muted-foreground">{t('rot.presetsHint')}</p>
|
||||
<div className="space-y-1.5">
|
||||
{rotorPresets.map((p, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input className="w-24" maxLength={6} value={p.label}
|
||||
placeholder={t('rot.presetName')}
|
||||
onChange={(e) => setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))} />
|
||||
<Input className="w-24" type="number" min={0} max={359} value={p.azimuth}
|
||||
onChange={(e) => setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, azimuth: Math.max(0, Math.min(359, parseInt(e.target.value, 10) || 0)) } : x)))} />
|
||||
<span className="text-xs text-muted-foreground">°</span>
|
||||
<Button variant="ghost" size="sm" title={t('rot.remove')}
|
||||
onClick={() => setRotorPresets((list) => list.filter((_, j) => j !== i))}>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{rotorPresets.length < 8 && (
|
||||
<Button variant="outline" size="sm" onClick={() => setRotorPresets((l) => [...l, { label: '', azimuth: 0 }])}>
|
||||
<Plus className="size-3.5 mr-1" /> {t('rot.presetAdd')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={() => { ResetRotorPresets().then((p) => setRotorPresets((p ?? []) as any)).catch((e) => setErr(String(e?.message ?? e))); }}>
|
||||
{t('rot.presetsReset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{rotatorTest && (
|
||||
<div className={cn(
|
||||
'text-xs rounded-md p-2.5 border',
|
||||
|
||||
@@ -300,6 +300,11 @@ const en: Dict = {
|
||||
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||
'tg2.hint': 'OpsLog talks to the 4O3A Tuner Genius XL over TCP (port fixed at 9010), so only the device IP is needed. A docked widget then shows SWR and forward power and offers Tune, Bypass and Operate/Standby. Control it directly (not through the radio) so OpsLog uses just one of the box\'s connection slots.', 'tg2.enable': 'Enable Tuner Genius control', 'tg2.portHint': 'The TCP port is fixed at 9010 on the device.', 'tg2.password': 'Remote code', 'tg2.passwordPh': 'blank on LAN', 'tg2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||
'rot.spidHint': 'Native SPID protocol over the controller’s COM port — no PstRotator needed. Pick the dialect above: Rot2Prog answers with azimuth and elevation at 600 baud, Rot1Prog with azimuth only at 1200.', 'rot.spidModel': 'SPID protocol',
|
||||
'rot.presets': 'Quick-turn buttons', 'rot.presetName': 'Name', 'rot.presetAdd': 'Add button',
|
||||
'rot.presetsHint': 'Shown beside the rotor dial. The azimuths started out computed from your station square, so they point at those regions FROM HERE — change either field, or clear a name to drop the button.',
|
||||
'rot.presetsReset': 'Recompute from my square',
|
||||
'rotor.go': 'GO', 'rotor.stop': 'STOP', 'rotor.azPh': '0 – 359',
|
||||
'rotor.azTitle': 'Azimuth 0-359, Enter to turn the rotor',
|
||||
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.testOkRead': 'Connected — the controller answered with its heading. Nothing was moved: this test only reads the position.', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.dual': 'Two rotors', 'rot.rotor1': 'Rotor 1', 'rot.rotor2': 'Rotor 2', 'rot.name': 'Name', 'rot.antenna': 'Antenna', 'rot.antenna1': 'Rotor 1 antenna', 'rot.antenna2': 'Rotor 2 antenna', 'rot.name1': 'Rotor 1 name', 'rot.name2': 'Rotor 2 name', 'rot.antStd': 'Standard antenna', 'rot.antMotor': 'Motorized (Ultrabeam/SteppIR)', 'rot.add': 'Add rotor', 'rot.remove': 'Remove rotor', 'rot.none': 'No rotor configured. Click “Add rotor” to add one.', 'rot.rgDual': 'This Rotator Genius drives two rotors (adds a second one)', 'rot.testRotor1': 'Test rotor 1', 'rot.testRotor2': 'Test rotor 2', 'rot.dualHint': 'Two independent rotors of any type — for example an ARCO and a Rotator Genius side by side. Configure each below; OpsLog shows a Rotor 1/2 toggle on the compass to pick which one it turns and displays. Set each rotor to "Motorized" so the boom/pattern paths (reverse, bidirectional) show only for the one that carries the Ultrabeam/SteppIR. (For a single Rotator Genius driving two rotors, set both to Rotator Genius with the same host and rotator # 1 and 2.)', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to any controller set to Yaesu GS-232A — no PstRotator needed. microHAM ARCO: set Config → LAN → CONTROL PROTOCOL (or USB CONTROL PROTOCOL) to 'Yaesu GS-232A'; on USB the baud rate does not matter. ERC (Easy Rotor Control, incl. ERC Mini): its emulation MUST be set to GS-232 — an ERC left on Hy-Gain DCU-1 speaks a different command set and will not answer — then pick its COM port and the same baud rate as in the ERC configuration.", 'rot.dcu1Hint': "Speaks the Hy-Gain DCU-1 command set (RotorCard DXA, Idiom Press Rotor-EZ, Green Heron). Connect over the controller's COM port (a DCU-1 is 4800 baud; RotorCard/Green Heron may differ — match the controller) or over TCP through a serial-over-IP bridge. Azimuth only, no elevation. New backend — please report if your controller needs a different command or baud.", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
||||
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
||||
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.motorBands': 'Covered bands', 'hw.motorStep': 'Re-tune step', 'hw.motorBandFreqHint': 'Frequency each band button tunes the antenna to (kHz). Leave empty for the default shown.', 'hw.motorFollow': 'Follow rig frequency (auto-tune the antenna)', 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||
@@ -310,8 +315,11 @@ const en: Dict = {
|
||||
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
||||
'cat.icomNetAudioHint': 'Play the rig’s received audio through your Listening device (Settings → Audio) over the 50003 stream. Experimental — the audio framing is pending on-rig verification; leave off if control misbehaves.',
|
||||
'cat.omnirigRig': 'OmniRig rig slot', 'cat.omnirigVfo': 'VFO to read', 'cat.omnirigVfoAuto': 'As reported by the rig file', 'cat.omnirigVfoA': 'Always VFO A (main)', 'cat.omnirigVfoB': 'Always VFO B (sub)', 'cat.omnirigVfoHint': 'OmniRig reports the active VFO from the rig file, and some files get it wrong — the frequency then follows the other VFO and appears frozen. Force one here if that happens.', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)", 'cat.flexDecodeSpots': 'Show WSJT-X decodes on the panadapter', 'cat.flexDecodeSpotsHint': '(heard FT8/FT4 stations from your WSJT-X/JTDX UDP feed, one spot per call)', 'cat.flexDecodeSecs': 'Display for', 'cat.flexDecodeSecsHint': 'seconds before a station is removed',
|
||||
'cat.flexDvkDax': 'Switch transmit audio to DAX for voice messages',
|
||||
'cat.flexDvkDaxHint': '(pressed while a DVK message or a QSO recording goes out, and put back afterwards so your microphone works again)',
|
||||
'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.icomModel': 'Rig model', 'cat.icomModelOther': 'Other (custom address)', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'Pick your model to set the CI-V address automatically (or choose "Other" and type it). Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.',
|
||||
'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)",
|
||||
'cat.offsetOn': 'Transverter offset', 'cat.offsetHint': '(the rig shows its IF; OpsLog logs, spots and tunes on the real band)', 'cat.offsetMhz': 'Offset (MHz)', 'cat.offsetExample': 'e.g. 116 for a 28 MHz IF on 144 MHz — negative is allowed',
|
||||
'cat.pollMs': 'Poll interval (ms)', 'cat.delayMs': 'CAT delay (ms)', 'cat.digitalDefault': 'Default digital mode (when rig reports DIG)', 'cat.modeBeforeFreq': 'Set mode before frequency', 'cat.modeBeforeFreqHint': '(older rigs that drop the mode after a band change)',
|
||||
'cat.omnirigHint': 'Configure your rig (COM port, baud rate, model) in OmniRig\'s own settings GUI first. OpsLog will read whichever Rig slot you select here. Set CAT delay above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu). OmniRig only reports generic "DIG" for digital modes — Default digital mode is the specific mode OpsLog will surface (and log).',
|
||||
'cat.rotatorOk': "Packet sent — antenna should swing to 0° (north). If it didn't, check PstRotator host/port and that PstRotator's UDP listener is enabled.",
|
||||
@@ -362,7 +370,7 @@ const en: Dict = {
|
||||
'chp.lotwRcvd': 'LoTW rcvd', 'chp.bureauRcvd': 'Bureau rcvd', 'chp.olderQsos': '+ {n} older QSOs',
|
||||
'bmp.statusNew': 'NEW DXCC (entity never worked)', 'bmp.statusNewBand': 'NEW BAND (entity not worked on this band)', 'bmp.statusNewSlot': 'NEW SLOT (mode not worked on this band)', 'bmp.statusNewCall': 'NEW CALL (this callsign never worked on this band and mode)', 'bmp.statusNewMode': 'NEW MODE (mode never worked for this entity)',
|
||||
'bmp.statusWorked': 'Worked (this band + mode already in log)', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.',
|
||||
'bmp.map': 'Map', 'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
|
||||
'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
|
||||
'bmp.bandsLabel': 'Bands:', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Hide FTx', 'bmp.hideFtTitle': 'Hide all digital (FT8/FT4/JS8/…) spots on every band map', 'bmp.fitBand': 'Fit to band', 'bmp.widthTip': 'Drag to resize — double-click to reset', 'bmp.fitTitle': 'Size each band map to show the whole band edge-to-edge',
|
||||
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.openUnusual': 'unusual for the season', 'bmp.legendWorked': 'Worked', 'bmp.legendNewPota': 'New POTA', 'bmp.legendNewCounty': 'New county', 'bmp.legendWorkedCall': 'Callsign already worked', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Data', 'bmp.legendPhone': 'Phone', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
|
||||
'frm.welcome': 'Welcome to OpsLog', 'frm.intro': 'Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).',
|
||||
@@ -740,6 +748,11 @@ const fr: Dict = {
|
||||
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||
'tg2.hint': "OpsLog dialogue avec le 4O3A Tuner Genius XL en TCP (port fixé à 9010), seule l'IP de l'appareil est nécessaire. Un widget ancré affiche le ROS et la puissance directe et propose Accord, Bypass et Operate/Standby. Pilotage direct (pas via la radio) pour n'utiliser qu'une des connexions de la boîte.", 'tg2.enable': "Activer le contrôle du Tuner Genius", 'tg2.portHint': "Le port TCP est fixé à 9010 sur l'appareil.", 'tg2.password': 'Code distant', 'tg2.passwordPh': 'vide en LAN', 'tg2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||
'rot.spidHint': 'Protocole SPID natif sur le port COM du contrôleur — sans PstRotator. Choisissez le dialecte ci-dessus : Rot2Prog répond azimut et élévation à 600 bauds, Rot1Prog azimut seul à 1200.', 'rot.spidModel': 'Protocole SPID',
|
||||
'rot.presets': 'Boutons de rotation rapide', 'rot.presetName': 'Nom', 'rot.presetAdd': 'Ajouter un bouton',
|
||||
'rot.presetsHint': "Affichés à côté du cadran du rotor. Les azimuts ont été calculés depuis ton carré : ils visent ces régions DEPUIS ICI. Modifie l'un ou l'autre champ, ou vide le nom pour retirer le bouton.",
|
||||
'rot.presetsReset': 'Recalculer depuis mon carré',
|
||||
'rotor.go': 'GO', 'rotor.stop': 'STOP', 'rotor.azPh': '0 – 359',
|
||||
'rotor.azTitle': 'Azimut 0-359, Entrée pour lancer le rotor',
|
||||
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.testOkRead': 'Connecté — le contrôleur a répondu avec son azimut. Rien n’a bougé : ce test ne fait que lire la position.', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.dual': 'Deux rotors', 'rot.rotor1': 'Rotor 1', 'rot.rotor2': 'Rotor 2', 'rot.name': 'Nom', 'rot.antenna': 'Antenne', 'rot.antenna1': 'Antenne rotor 1', 'rot.antenna2': 'Antenne rotor 2', 'rot.name1': 'Nom du rotor 1', 'rot.name2': 'Nom du rotor 2', 'rot.antStd': 'Antenne standard', 'rot.antMotor': 'Motorisée (Ultrabeam/SteppIR)', 'rot.add': 'Ajouter un rotor', 'rot.remove': 'Supprimer le rotor', 'rot.none': 'Aucun rotor configuré. Cliquez sur « Ajouter un rotor ».', 'rot.rgDual': 'Ce Rotator Genius pilote deux rotors (en ajoute un second)', 'rot.testRotor1': 'Tester rotor 1', 'rot.testRotor2': 'Tester rotor 2', 'rot.dualHint': 'Deux rotors indépendants de n\'importe quel type — par exemple un ARCO et un Rotator Genius côte à côte. Configurez chacun ci-dessous ; OpsLog affiche un sélecteur Rotor 1/2 sur le compas pour choisir celui qu\'il tourne et affiche. Réglez chaque rotor sur « Motorisée » pour que les tracés de boom/diagramme (inverse, bidirectionnel) n\'apparaissent que pour celui qui porte l\'Ultrabeam/SteppIR. (Pour un seul Rotator Genius pilotant deux rotors, réglez les deux sur Rotator Genius avec le même hôte et le rotator n° 1 et 2.)', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.dcu1Hint': "Parle le jeu de commandes Hy-Gain DCU-1 (RotorCard DXA, Idiom Press Rotor-EZ, Green Heron). Connexion via le port COM du contrôleur (un DCU-1 est à 4800 bauds ; RotorCard/Green Heron peuvent différer — reprendre la vitesse du contrôleur) ou en TCP via un pont série-sur-IP. Azimut uniquement, pas d'élévation. Nouveau pilote — signalez si votre contrôleur nécessite une autre commande ou vitesse.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
||||
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
||||
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.motorBands': 'Bandes couvertes', 'hw.motorStep': 'Pas de réaccord', 'hw.motorBandFreqHint': "Fréquence sur laquelle chaque bouton de bande accorde l'antenne (kHz). Laisser vide pour le défaut affiché.", 'hw.motorFollow': "Suivre la fréquence du rig (accord auto de l'antenne)", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
||||
@@ -749,8 +762,11 @@ const fr: Dict = {
|
||||
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||
'cat.icomNetAudioHint': 'Écoute l’audio reçu du poste sur ton périphérique d’écoute (Réglages → Audio) via le flux 50003. Expérimental — le format audio reste à vérifier sur le poste ; laisse désactivé si le contrôle se comporte mal.',
|
||||
'cat.omnirigRig': 'Slot OmniRig', 'cat.omnirigVfo': 'VFO à lire', 'cat.omnirigVfoAuto': 'Selon le fichier radio', 'cat.omnirigVfoA': 'Toujours le VFO A (principal)', 'cat.omnirigVfoB': 'Toujours le VFO B (secondaire)', 'cat.omnirigVfoHint': "OmniRig indique le VFO actif d'après le fichier radio, et certains fichiers se trompent — la fréquence suit alors l'autre VFO et paraît figée. Forcez-en un ici le cas échéant.", 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)", 'cat.flexDecodeSpots': 'Afficher les décodes WSJT-X sur le panadapter', 'cat.flexDecodeSpotsHint': '(stations FT8/FT4 entendues via ton flux UDP WSJT-X/JTDX, un spot par station)', 'cat.flexDecodeSecs': 'Affichage pendant', 'cat.flexDecodeSecsHint': 'secondes avant retrait d\'une station',
|
||||
'cat.flexDvkDax': "Basculer l'audio d'émission sur DAX pour les messages vocaux",
|
||||
'cat.flexDvkDaxHint': "(enfoncé le temps d'un message DVK ou d'un enregistrement de QSO, puis remis comme avant pour retrouver ton micro)",
|
||||
'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.icomModel': 'Modèle de poste', 'cat.icomModelOther': 'Autre (adresse perso)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'Choisis ton modèle pour fixer l’adresse CI-V automatiquement (ou « Autre » et saisis-la). Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.',
|
||||
'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)",
|
||||
'cat.offsetOn': 'Décalage transverter', 'cat.offsetHint': "(le poste affiche sa FI ; OpsLog logue, spotte et accorde sur la vraie bande)", 'cat.offsetMhz': 'Décalage (MHz)', 'cat.offsetExample': 'p. ex. 116 pour une FI 28 MHz sur 144 MHz — négatif accepté',
|
||||
'cat.pollMs': 'Intervalle de poll (ms)', 'cat.delayMs': 'Délai CAT (ms)', 'cat.digitalDefault': 'Mode numérique par défaut (quand le poste indique DIG)', 'cat.modeBeforeFreq': 'Régler le mode avant la fréquence', 'cat.modeBeforeFreqHint': '(anciens postes qui perdent le mode après un changement de bande)',
|
||||
'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).",
|
||||
'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.",
|
||||
@@ -796,7 +812,7 @@ const fr: Dict = {
|
||||
'chp.lotwRcvd': 'LoTW reçue', 'chp.bureauRcvd': 'Bureau reçue', 'chp.olderQsos': '+ {n} QSO plus anciens',
|
||||
'bmp.statusNew': 'NOUVEAU DXCC (entité jamais contactée)', 'bmp.statusNewBand': 'NOUVELLE BANDE (entité non contactée sur cette bande)', 'bmp.statusNewSlot': 'NOUVEAU MODE (mode non contacté sur cette bande)', 'bmp.statusNewCall': "CALL NEUF (indicatif jamais contacté sur cette bande et ce mode)", 'bmp.statusNewMode': 'NOUVEAU MODE (mode jamais contacté pour cette entité)',
|
||||
'bmp.statusWorked': 'Contacté (cette bande + mode déjà au log)', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.',
|
||||
'bmp.map': 'Carte', 'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
|
||||
'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
|
||||
'bmp.bandsLabel': 'Bandes :', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Masquer FTx', 'bmp.hideFtTitle': 'Masquer tous les spots numériques (FT8/FT4/JS8/…) sur toutes les cartes', 'bmp.fitBand': 'Ajuster à la bande', 'bmp.widthTip': 'Glisser pour redimensionner — double-clic pour réinitialiser', 'bmp.fitTitle': 'Dimensionner chaque carte pour afficher toute la bande',
|
||||
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.openUnusual': 'inhabituel pour la saison', 'bmp.legendWorked': 'Contacté', 'bmp.legendNewPota': 'Nouveau POTA', 'bmp.legendNewCounty': 'Nouveau comté', 'bmp.legendWorkedCall': 'Indicatif déjà contacté', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Numérique', 'bmp.legendPhone': 'Phonie', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
|
||||
'frm.welcome': 'Bienvenue dans OpsLog', 'frm.intro': 'Configure ta station pour commencer à logger. Ces champs sont inscrits sur chaque QSO et peuvent être modifiés plus tard dans Préférences → Informations station (et par profil).',
|
||||
|
||||
@@ -44,6 +44,7 @@ const PORTABLE_KEYS = [
|
||||
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
|
||||
'opslog.bandMapWidth', // docked band map: column width (px)
|
||||
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
|
||||
'opslog.bandMapZoom', // band map zoom (px/kHz step) remembered per band, as one {band: index} map
|
||||
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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).
|
||||
export const APP_VERSION = '0.25.8';
|
||||
export const APP_VERSION = '0.25.9';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+6
@@ -513,6 +513,8 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||
|
||||
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
||||
|
||||
export function GetRotorPresets():Promise<Array<main.RotorPreset>>;
|
||||
|
||||
export function GetRowColors():Promise<main.RowColorSettings>;
|
||||
|
||||
export function GetSPEStatus():Promise<spe.Status>;
|
||||
@@ -883,6 +885,8 @@ export function ResetAwardDefs():Promise<Array<award.Def>>;
|
||||
|
||||
export function ResetDatabaseToDefault():Promise<void>;
|
||||
|
||||
export function ResetRotorPresets():Promise<Array<main.RotorPreset>>;
|
||||
|
||||
export function RestartApp():Promise<void>;
|
||||
|
||||
export function RestartQSORecorder():Promise<void>;
|
||||
@@ -973,6 +977,8 @@ export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||
|
||||
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
|
||||
|
||||
export function SaveRotorPresets(arg1:Array<main.RotorPreset>):Promise<void>;
|
||||
|
||||
export function SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
|
||||
|
||||
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
|
||||
|
||||
@@ -966,6 +966,10 @@ export function GetRotators() {
|
||||
return window['go']['main']['App']['GetRotators']();
|
||||
}
|
||||
|
||||
export function GetRotorPresets() {
|
||||
return window['go']['main']['App']['GetRotorPresets']();
|
||||
}
|
||||
|
||||
export function GetRowColors() {
|
||||
return window['go']['main']['App']['GetRowColors']();
|
||||
}
|
||||
@@ -1706,6 +1710,10 @@ export function ResetDatabaseToDefault() {
|
||||
return window['go']['main']['App']['ResetDatabaseToDefault']();
|
||||
}
|
||||
|
||||
export function ResetRotorPresets() {
|
||||
return window['go']['main']['App']['ResetRotorPresets']();
|
||||
}
|
||||
|
||||
export function RestartApp() {
|
||||
return window['go']['main']['App']['RestartApp']();
|
||||
}
|
||||
@@ -1886,6 +1894,10 @@ export function SaveRotators(arg1) {
|
||||
return window['go']['main']['App']['SaveRotators'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRotorPresets(arg1) {
|
||||
return window['go']['main']['App']['SaveRotorPresets'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRowColors(arg1) {
|
||||
return window['go']['main']['App']['SaveRowColors'](arg1);
|
||||
}
|
||||
|
||||
@@ -2021,6 +2021,7 @@ export namespace main {
|
||||
flex_host: string;
|
||||
flex_port: number;
|
||||
flex_spots: boolean;
|
||||
flex_dvk_dax: boolean;
|
||||
flex_decode_spots: boolean;
|
||||
flex_decode_secs: number;
|
||||
xiegu_port: string;
|
||||
@@ -2047,6 +2048,8 @@ export namespace main {
|
||||
tci_spots: boolean;
|
||||
poll_ms: number;
|
||||
delay_ms: number;
|
||||
offset_on: boolean;
|
||||
offset_hz: number;
|
||||
digital_default: string;
|
||||
share_enabled: boolean;
|
||||
share_port: number;
|
||||
@@ -2069,6 +2072,7 @@ export namespace main {
|
||||
this.flex_host = source["flex_host"];
|
||||
this.flex_port = source["flex_port"];
|
||||
this.flex_spots = source["flex_spots"];
|
||||
this.flex_dvk_dax = source["flex_dvk_dax"];
|
||||
this.flex_decode_spots = source["flex_decode_spots"];
|
||||
this.flex_decode_secs = source["flex_decode_secs"];
|
||||
this.xiegu_port = source["xiegu_port"];
|
||||
@@ -2095,6 +2099,8 @@ export namespace main {
|
||||
this.tci_spots = source["tci_spots"];
|
||||
this.poll_ms = source["poll_ms"];
|
||||
this.delay_ms = source["delay_ms"];
|
||||
this.offset_on = source["offset_on"];
|
||||
this.offset_hz = source["offset_hz"];
|
||||
this.digital_default = source["digital_default"];
|
||||
this.share_enabled = source["share_enabled"];
|
||||
this.share_port = source["share_port"];
|
||||
@@ -3206,6 +3212,20 @@ export namespace main {
|
||||
this.motorized = source["motorized"];
|
||||
}
|
||||
}
|
||||
export class RotorPreset {
|
||||
label: string;
|
||||
azimuth: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RotorPreset(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.label = source["label"];
|
||||
this.azimuth = source["azimuth"];
|
||||
}
|
||||
}
|
||||
export class RowColorRule {
|
||||
id: string;
|
||||
color: string;
|
||||
|
||||
Reference in New Issue
Block a user