Compare commits

..
4 Commits
16 changed files with 576 additions and 54 deletions
+1
View File
@@ -46,3 +46,4 @@ cat.log
.env.local
*.pem
*.key
*.syso
+78 -7
View File
@@ -49,6 +49,7 @@ import (
"hamlog/internal/qslcard"
"hamlog/internal/qso"
"hamlog/internal/relaydev"
"hamlog/internal/rotator/gs232"
"hamlog/internal/rotator/pst"
"hamlog/internal/rotgenius"
"hamlog/internal/settings"
@@ -158,8 +159,10 @@ const (
keyRotatorHost = "rotator.host"
keyRotatorPort = "rotator.port"
keyRotatorHasElevation = "rotator.has_elevation"
keyRotatorType = "rotator.type" // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP)
keyRotatorType = "rotator.type" // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (microHAM ARCO, GS-232A)
keyRotatorNum = "rotator.num" // Rotator Genius rotator index: 1 or 2
keyRotatorTransport = "rotator.transport" // arco: "tcp" (LAN) | "serial" (USB virtual COM)
keyRotatorComPort = "rotator.com_port" // arco serial transport
// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
// "ultrabeam." prefix for backward compatibility with configs saved before the
@@ -11214,7 +11217,14 @@ func (a *App) ActivateProfile(id int64) error {
// target (local SQLite or its own MySQL) so QSOs go to the right logbook.
if p, err := a.profiles.Get(a.ctx, id); err == nil {
if err := a.switchLogbook(p); err != nil {
// The switch failed (typically: this profile's MySQL is unreachable right
// now) and the PREVIOUS logbook stays live. Silently staying on the old
// database is how QSOs end up in the wrong log — tell the operator.
applog.Printf("activate profile %d: logbook switch failed: %v", id, err)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf(
"Profil %q : connexion à sa base impossible — l'ancienne base reste active ! (%v)", p.Name, err))
}
}
}
// Re-apply every settings-dependent subsystem for the new profile.
@@ -11276,11 +11286,13 @@ func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error
// RotatorSettings is the JSON shape for the Hardware → Rotator panel.
type RotatorSettings struct {
Enabled bool `json:"enabled"`
Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP)
Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (microHAM ARCO, Yaesu GS-232A over TCP)
Host string `json:"host"` // default 127.0.0.1
Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius)
Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius) / 4001 (arco — must match the port set in ARCO's LAN CONTROL PROTOCOL)
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
RotatorNum int `json:"rotator_num"` // Rotator Genius index: 1 or 2
Transport string `json:"transport"` // arco: "tcp" (LAN) | "serial" (USB virtual COM)
ComPort string `json:"com_port"` // arco serial transport
}
// GetRotatorSettings returns the persisted rotator config with defaults.
@@ -11290,12 +11302,13 @@ func (a *App) GetRotatorSettings() (RotatorSettings, error) {
return out, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx,
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum)
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum,
keyRotatorTransport, keyRotatorComPort)
if err != nil {
return out, err
}
out.Enabled = m[keyRotatorEnabled] == "1"
if t := m[keyRotatorType]; t == "rotgenius" || t == "pst" {
if t := m[keyRotatorType]; t == "rotgenius" || t == "pst" || t == "arco" {
out.Type = t
}
if h := m[keyRotatorHost]; h != "" {
@@ -11305,11 +11318,18 @@ func (a *App) GetRotatorSettings() (RotatorSettings, error) {
out.Port = p
} else if out.Type == "rotgenius" {
out.Port = 9006 // native default when no port stored yet
} else if out.Type == "arco" {
out.Port = 4001 // placeholder — the real number is set in ARCO's LAN menu
}
out.HasElevation = m[keyRotatorHasElevation] == "1"
if rn, _ := strconv.Atoi(m[keyRotatorNum]); rn == 2 {
out.RotatorNum = 2
}
out.Transport = "tcp"
if m[keyRotatorTransport] == "serial" {
out.Transport = "serial"
}
out.ComPort = m[keyRotatorComPort]
return out, nil
}
@@ -11319,18 +11339,21 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if s.Type != "rotgenius" {
if s.Type != "rotgenius" && s.Type != "arco" {
s.Type = "pst"
}
if s.Host == "" {
s.Host = "127.0.0.1"
}
if s.Port <= 0 || s.Port > 65535 {
s.Port = map[string]int{"rotgenius": 9006, "pst": 12000}[s.Type]
s.Port = map[string]int{"rotgenius": 9006, "pst": 12000, "arco": 4001}[s.Type]
}
if s.RotatorNum != 2 {
s.RotatorNum = 1
}
if s.Transport != "serial" {
s.Transport = "tcp"
}
for k, v := range map[string]string{
keyRotatorEnabled: boolStr(s.Enabled),
keyRotatorType: s.Type,
@@ -11338,6 +11361,8 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
keyRotatorPort: strconv.Itoa(s.Port),
keyRotatorHasElevation: boolStr(s.HasElevation),
keyRotatorNum: strconv.Itoa(s.RotatorNum),
keyRotatorTransport: s.Transport,
keyRotatorComPort: strings.TrimSpace(s.ComPort),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -11346,6 +11371,15 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
return nil
}
// arcoClient builds the GS-232 client for the configured ARCO transport:
// USB virtual COM (serial) or the LAN CONTROL PROTOCOL TCP port.
func arcoClient(s RotatorSettings) *gs232.Client {
if s.Transport == "serial" {
return gs232.NewSerial(s.ComPort)
}
return gs232.New(s.Host, s.Port)
}
// RotatorHeading is the live antenna heading for the status bar.
type RotatorHeading struct {
Enabled bool `json:"enabled"`
@@ -11371,6 +11405,13 @@ func (a *App) GetRotatorHeading() RotatorHeading {
}
return RotatorHeading{Enabled: true, OK: true, Azimuth: st.Azimuth, Raw: raw}
}
if s.Type == "arco" {
az, raw, herr := arcoClient(s).Heading()
if herr != nil {
return RotatorHeading{Enabled: true, OK: false, Raw: herr.Error()}
}
return RotatorHeading{Enabled: true, OK: true, Azimuth: az, Raw: raw}
}
az, raw, herr := pst.New(s.Host, s.Port).Heading()
if herr != nil {
return RotatorHeading{Enabled: true, OK: false, Raw: raw}
@@ -11391,6 +11432,9 @@ func (a *App) RotatorGoTo(az int, el int) error {
if s.Type == "rotgenius" {
return rotgenius.New(s.Host, s.Port).GoTo(s.RotatorNum, az)
}
if s.Type == "arco" {
return arcoClient(s).GoTo(az)
}
return pst.New(s.Host, s.Port).GoTo(az, s.HasElevation, el)
}
@@ -11406,6 +11450,9 @@ func (a *App) RotatorStop() error {
if s.Type == "rotgenius" {
return rotgenius.New(s.Host, s.Port).Stop()
}
if s.Type == "arco" {
return arcoClient(s).Stop()
}
return pst.New(s.Host, s.Port).Stop()
}
@@ -11423,6 +11470,9 @@ func (a *App) RotatorPark() error {
if s.Type == "rotgenius" {
return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
}
if s.Type == "arco" {
return fmt.Errorf("park is a PstRotator feature; not available over the ARCO GS-232 link")
}
return pst.New(s.Host, s.Port).Park()
}
@@ -11447,6 +11497,18 @@ func (a *App) TestRotator(s RotatorSettings) error {
// reply, so a rejected command surfaces as an error instead of a false OK.
return rotgenius.New(s.Host, s.Port).GoTo(rn, 0)
}
if s.Type == "arco" {
if s.Port <= 0 || s.Port > 65535 {
s.Port = 4001
}
if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" {
return fmt.Errorf("select the ARCO's COM port first")
}
// A heading query proves the link AND that the ARCO port really speaks
// GS-232 — without moving the antenna.
_, _, err := arcoClient(s).Heading()
return err
}
if s.Port <= 0 || s.Port > 65535 {
s.Port = 12000
}
@@ -12463,6 +12525,15 @@ func (a *App) GetPGXLStatus() powergenius.Status {
return a.pgxl.GetStatus()
}
// PGXLSetOperate puts the PowerGenius XL in OPERATE (true) or STANDBY (false)
// over its direct TCP link (no Flex needed).
func (a *App) PGXLSetOperate(on bool) error {
if a.pgxl == nil {
return fmt.Errorf("PowerGenius not connected — enable it in Settings → Amplifier")
}
return a.pgxl.SetOperate(on)
}
// PGXLSetFanMode sets the amplifier fan mode (STANDARD | CONTEST | BROADCAST).
func (a *App) PGXLSetFanMode(mode string) error {
if a.pgxl == nil {
+18
View File
@@ -1,4 +1,22 @@
[
{
"version": "0.20.8",
"date": "2026-07-21",
"en": [
"New: microHAM ARCO rotator controlled natively — no PstRotator needed. Over the LAN or over USB: set the ARCO's CONTROL PROTOCOL (LAN or USB) to 'Yaesu GS-232A' and pick 'microHAM ARCO' in Settings → Rotator.",
"Amplifier without a FlexRadio: the amplifier controls (OPERATE/STANDBY, ON/OFF, live power/SWR/temperature) now also live in the Station Control tab, and a status chip sits in the bottom bar next to Cluster/CAT/Rotator — green ON AIR = OPERATE, orange = STANDBY, red = offline. Clicking the chip toggles OPERATE/STANDBY on all three amps (PowerGenius XL included, over its direct network link).",
"NET Control: after logging a station, the next on-air station is selected automatically (chain log → log → log), and a new 'Log everyone' button logs every on-air station at once when the net ends.",
"Switching to a profile whose MySQL database is unreachable now shows a clear warning — before, OpsLog silently stayed on the previous logbook and QSOs could land in the wrong log.",
"The ON AIR badge is back for local (SQLite) logbooks — it had disappeared with the removal of the live-status option."
],
"fr": [
"Nouveau : rotator microHAM ARCO contrôlé en natif — sans PstRotator. Par le réseau ou par USB : régler le CONTROL PROTOCOL de l'ARCO (LAN ou USB) sur « Yaesu GS-232A » et choisir « microHAM ARCO » dans Réglages → Rotator.",
"Amplificateur sans FlexRadio : les commandes d'ampli (OPERATE/STANDBY, ON/OFF, puissance/ROS/température en direct) sont maintenant aussi dans l'onglet Station Control, et une pastille de statut s'affiche dans la barre du bas à côté de Cluster/CAT/Rotator — vert = OPERATE, orange = STANDBY, rouge = hors ligne. Un clic sur la pastille bascule OPERATE/STANDBY sur les trois amplis (PowerGenius XL compris, via son lien réseau direct).",
"NET Control : après avoir loggé une station, la station on air suivante est sélectionnée automatiquement (enchaînement log → log → log), et un nouveau bouton « Logger tout le monde » enregistre toutes les stations on air d'un coup à la fin du net.",
"Passer sur un profil dont la base MySQL est injoignable affiche maintenant un avertissement clair — avant, OpsLog restait silencieusement sur l'ancien logbook et les QSO pouvaient partir dans le mauvais log.",
"Le badge ON AIR est de retour pour les logbooks locaux (SQLite) — il avait disparu avec la suppression de l'option de statut en direct."
]
},
{
"version": "0.20.7",
"date": "2026-07-21",
+58 -2
View File
@@ -43,6 +43,7 @@ import {
GetAwardDefs,
GetUIPref, GetActiveProfile, QuitApp,
ReportLiveActivity, LiveLastQSOAgeSec,
GetPGXLSettings, GetPGXLStatus, GetSPEStatus, GetACOMStatus, SPESetOperate, ACOMSetOperate, PGXLSetOperate,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs';
@@ -436,6 +437,27 @@ export default function App() {
// click reverts the UI and the click looks like it did nothing.
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
// Amplifier chip in the status bar: name + green OPERATE / orange STANDBY /
// red offline. Config re-read every 5s (so enabling the amp in Settings makes
// the chip appear); status polled every 2s while enabled.
const [ampCfg, setAmpCfg] = useState<{ enabled: boolean; type: string }>({ enabled: false, type: 'pgxl' });
const [ampSt, setAmpSt] = useState<any>({ connected: false });
useEffect(() => {
let alive = true;
const load = () => GetPGXLSettings().then((s: any) => { if (alive) setAmpCfg({ enabled: !!s?.enabled, type: s?.type || 'pgxl' }); }).catch(() => {});
load();
const id = window.setInterval(load, 5000);
return () => { alive = false; window.clearInterval(id); };
}, []);
useEffect(() => {
if (!ampCfg.enabled) { setAmpSt({ connected: false }); return; }
let alive = true;
const get = ampCfg.type === 'pgxl' ? GetPGXLStatus : ampCfg.type.startsWith('acom') ? GetACOMStatus : GetSPEStatus;
const tick = () => get().then((s: any) => alive && setAmpSt(s || { connected: false })).catch(() => {});
tick();
const id = window.setInterval(tick, 2000);
return () => { alive = false; window.clearInterval(id); };
}, [ampCfg.enabled, ampCfg.type]);
// 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.
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
@@ -5122,7 +5144,42 @@ export default function App() {
disabled={!rotatorHeading.enabled}
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
/>
{liveStationsOn && (
{/* Amplifier chip: green = OPERATE, orange = STANDBY, red = offline.
CLICK toggles OPERATE STANDBY on every backend (PGXL included
its direct TCP link takes operate=0/1); optimistic flip, the 2s
poll reconciles. Offline click opens the settings instead. */}
{ampCfg.enabled && (() => {
const isPGXL = ampCfg.type === 'pgxl';
const name = isPGXL ? 'PGXL'
: ampCfg.type.startsWith('acom') ? `ACOM ${ampSt.model || ''}`.trim()
: `SPE ${ampSt.model || ''}`.trim();
const dot = !ampSt.connected ? 'bg-danger' : ampSt.operate ? 'bg-success' : 'bg-warning';
const state = !ampSt.connected ? (ampSt.last_error || 'offline') : ampSt.operate ? 'OPERATE' : 'STANDBY';
const toggle = () => {
// Offline — nothing to command; open the settings to fix the link.
if (!ampSt.connected) { setSettingsSection('pgxl'); setShowSettings(true); return; }
const want = !ampSt.operate;
setAmpSt((s: any) => ({ ...s, operate: want }));
(isPGXL ? PGXLSetOperate(want)
: ampCfg.type.startsWith('acom') ? ACOMSetOperate(want)
: SPESetOperate(want)).catch(() => {});
};
return (
<button
type="button"
title={`${name}: ${state}${ampSt.connected ? (ampSt.operate ? ' — click for STANDBY' : ' — click for OPERATE') : ' — click for settings'}`}
onClick={toggle}
className="inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors border-border hover:bg-muted cursor-pointer"
>
<span className={cn('size-2 rounded-full', dot)} />
{name}
</button>
);
})()}
{/* ON AIR badge: "did I log a QSO in the last 5 min" meaningful on ANY
logbook backend (only the live_status PUBLISHING is MySQL-specific),
so it is always shown. Gating it on MySQL made it vanish for
local-SQLite operators when the Settings toggle was removed. */}
<div
className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
@@ -5131,7 +5188,6 @@ export default function App() {
<span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
{onAir ? t('live.onAir') : t('live.offline')}
</div>
)}
{/* Toasts / errors: the status bar's free space is far wider than the
header band they used to sit in. Still one line (the bar is 28px),
but CLICK opens the full text wrapped long messages (a TQSL or
+32 -2
View File
@@ -61,6 +61,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
// Programmatic row selection in the on-air grid (auto-advance after logging).
const [selectRow, setSelectRow] = useState<{ id: number; seq: number }>({ id: 0, seq: 0 });
// Add/edit-contact dialog.
const [contactOpen, setContactOpen] = useState(false);
@@ -177,10 +179,34 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Active → logged (end QSO, removed from session, written to the logbook).
// Then auto-select the FIRST remaining on-air station, so the operator can
// chain "log → log → log" without re-clicking a row each time.
async function deactivate(id?: number) {
if (id == null) return;
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
catch (e: any) { setError(String(e?.message ?? e)); }
const next = active.find((a) => a.id !== id); // computed BEFORE the list shrinks
try {
await NetDeactivate(id);
await refreshActive();
onLogged?.();
if (next?.id != null) {
setSelectRow((s) => ({ id: next.id as number, seq: s.seq + 1 }));
setSelectedActiveIds([next.id as number]);
showWorkedBefore(next.callsign, (next as any).dxcc ?? 0);
}
} catch (e: any) { setError(String(e?.message ?? e)); }
}
// Log EVERYONE still on air (end of net): each draft is written to the logbook
// exactly as the single-log button would.
async function logAll() {
if (active.length === 0) return;
if (!window.confirm(t('ncp.logAllConfirm', { n: active.length }))) return;
try {
for (const a of [...active]) {
if (a.id != null) await NetDeactivate(a.id as number);
}
await refreshActive();
onLogged?.();
} catch (e: any) { setError(String(e?.message ?? e)); await refreshActive(); }
}
// Edit-modal handlers (operate on the in-memory draft, not the DB).
@@ -286,6 +312,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
rows={active}
total={active.length}
storageKey="net.onair"
selectRowSignal={selectRow}
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
onRowDoubleClicked={(q) => setEditingDraft(q)}
onRowSelected={setSelectedActiveIds}
@@ -297,6 +324,9 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto" onClick={logAll}>
<MinusCircle className="size-3.5" /> {t('ncp.logAll', { n: active.length })}
</Button>
</div>
)}
+19 -1
View File
@@ -30,6 +30,9 @@ type Props = {
// Bump this number to programmatically select every row (e.g. after a search
// in the QSL Manager, where the default is "all selected").
selectAllSignal?: number;
// Bump `seq` to programmatically select the single row with this id (e.g. NET
// Control auto-advancing to the next on-air station after logging one).
selectRowSignal?: { id: number; seq: number };
// storageKey scopes the persisted column layout/visibility. Omit for the main
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
// reuse this grid elsewhere with its OWN independent column config.
@@ -249,7 +252,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -406,6 +409,21 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
gridRef.current?.api?.selectAll();
}, [selectAllSignal]);
// Select ONE row by id when the caller bumps selectRowSignal.seq. Deferred a
// tick so a row-data refresh in the same render settles first.
useEffect(() => {
if (!selectRowSignal || selectRowSignal.seq === 0) return;
const t = window.setTimeout(() => {
const api = gridRef.current?.api;
if (!api) return;
api.deselectAll();
api.forEachNode((n: any) => {
if (n.data?.id === selectRowSignal.id) n.setSelected(true);
});
}, 0);
return () => window.clearTimeout(t);
}, [selectRowSignal?.seq]);
// ── Column picker (visibility) ──
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
// state for "which columns are visible" — AG Grid's column state is the
+44 -8
View File
@@ -2853,11 +2853,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
function RotatorPanel() {
const isRG = (rotator as any).type === 'rotgenius';
const isARCO = (rotator as any).type === 'arco';
return (
<>
<SectionHeader
title="Rotator"
hint={isRG ? undefined : t('rot.hint')}
hint={isRG || isARCO ? undefined : t('rot.hint')}
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -2867,14 +2868,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('rot.type')}</Label>
{/* Switching to Rotator Genius moves the default port to its native
9006; back to PstRotator restores 12000. */}
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
<Select value={(rotator as any).type ?? 'pst'}
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : 12000 } as any))}>
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 } as any))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
<SelectItem value="arco">microHAM ARCO (LAN, GS-232A)</SelectItem>
</SelectContent>
</Select>
</div>
@@ -2891,34 +2893,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</Select>
</div>
)}
{/* The ARCO is reachable over the LAN (TCP) or its USB virtual COM. */}
{isARCO && (
<div className="space-y-1">
<Label>Connection</Label>
<Select value={(rotator as any).transport ?? 'tcp'}
onValueChange={(v) => setRotator((s) => ({ ...s, transport: v } as any))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="tcp">Network (LAN)</SelectItem>
<SelectItem value="serial">USB (serial COM)</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{isARCO && (rotator as any).transport === 'serial' ? (
<div className="space-y-1 max-w-xs">
<Label>COM port</Label>
<div className="flex items-center gap-2">
<Select value={(rotator as any).com_port || '_'}
onValueChange={(v) => setRotator((s) => ({ ...s, com_port: v === '_' ? '' : v } as any))}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
<SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
<ArrowDown className="size-3.5 rotate-90" />
</Button>
</div>
</div>
) : (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host / IP</Label>
<Input
value={rotator.host ?? ''}
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
placeholder={isRG ? '192.168.1.60' : '127.0.0.1'}
placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'}
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label>{isRG ? 'TCP port' : 'UDP port'}</Label>
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
<Input
type="number" min={1} max={65535}
value={rotator.port}
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : 12000) }))}
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : isARCO ? 4001 : 12000) }))}
className="font-mono"
/>
</div>
</div>
{!isRG && (
)}
{!isRG && !isARCO && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
This rotator supports elevation (VHF / satellite)
</label>
)}
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
<div className="flex items-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
+129 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, Flame } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -13,6 +13,9 @@ import {
GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
GetPGXLSettings, GetPGXLStatus, PGXLSetFanMode, PGXLSetOperate,
GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel,
GetACOMStatus, ACOMSetOperate, ACOMSetPower,
} from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
@@ -246,6 +249,116 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
);
}
// AmplifierWidget brings the amplifier controls (Settings → Amplifier) into the
// Station Control tab, so an operator WITHOUT a FlexRadio/Icom panel still has
// them (the FlexPanel card only exists when a Flex is the rig). Same backends:
// SPE Expert / ACOM (full control) and PowerGenius XL (fan mode + state).
function AmplifierWidget({ ampType, t }: { ampType: string; t: (k: string, v?: any) => string }) {
const isACOM = ampType.startsWith('acom');
const isPGXL = ampType === 'pgxl';
const [st, setSt] = useState<any>({ connected: false });
useEffect(() => {
let alive = true;
const get = isPGXL ? GetPGXLStatus : isACOM ? GetACOMStatus : GetSPEStatus;
const tick = () => get().then((s: any) => alive && setSt(s || { connected: false })).catch(() => {});
tick();
const id = window.setInterval(tick, 1500);
return () => { alive = false; window.clearInterval(id); };
}, [ampType, isACOM, isPGXL]);
const title = isPGXL ? 'PowerGenius XL' : isACOM ? `ACOM ${st.model || ''}` : `SPE ${st.model || 'Expert'}`;
const maxW = isACOM ? (Number(st.max_w) || 800) : ({ '13K': 1300, '15K': 1500, '2K': 2000 } as Record<string, number>)[st.model] || 1500;
const outW = Number(isACOM ? st.fwd_w : st.output_w) || 0;
const frac = Math.min(1, outW / maxW);
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Flame className="size-4 text-primary" />
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{t('flxp.amplifier')}</div>
<div className="text-[10px] text-muted-foreground font-mono truncate">{title}</div>
</div>
<span className={cn('ml-auto size-2 rounded-full shrink-0', st.connected ? 'bg-success' : 'bg-muted-foreground/40')}
title={st.connected ? t('station.online') : (st.last_error || t('station.offline'))} />
</div>
<div className="p-3 space-y-2">
{isPGXL ? (
<div className="flex items-center gap-2 flex-wrap">
<button type="button" disabled={!st.connected}
onClick={() => { const want = !st.operate; setSt((s: any) => ({ ...s, operate: want })); PGXLSetOperate(want).catch(() => {}); }}
className={cn('px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 transition-all disabled:opacity-40',
st.operate ? 'bg-warning text-warning-foreground border-warning' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{st.operate ? 'OPERATE' : 'STANDBY'}
</button>
{(['STANDARD', 'CONTEST', 'BROADCAST'] as const).map((m) => (
<button key={m} type="button" disabled={!st.connected}
onClick={() => PGXLSetFanMode(m).catch(() => {})}
className={cn('px-2.5 py-1.5 rounded-md border text-xs font-semibold disabled:opacity-40',
st.fan_mode === m ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted/30 border-border hover:bg-muted')}>
{m === 'STANDARD' ? t('flxp.fanStandard') : m === 'CONTEST' ? t('flxp.fanContest') : t('flxp.fanBroadcast')}
</button>
))}
<span className="text-xs text-muted-foreground font-mono">{st.state || ''}{st.temperature ? ` · ${Math.round(st.temperature)}°C` : ''}</span>
</div>
) : (
<>
<div className="flex items-center gap-2 flex-wrap">
<button type="button" disabled={!st.connected}
onClick={() => (isACOM ? ACOMSetOperate(!st.operate) : SPESetOperate(!st.operate)).catch(() => {})}
className={cn('px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 transition-all disabled:opacity-40',
st.operate ? 'bg-warning text-warning-foreground border-warning' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{st.operate ? 'OPERATE' : 'STANDBY'}
</button>
<div className="inline-flex rounded-lg overflow-hidden border border-success/70">
<button type="button"
disabled={isACOM ? !(st.port_open && st.transport === 'serial') : !st.connected}
onClick={() => (isACOM ? ACOMSetPower(true) : SPESetPower(true)).catch(() => {})}
className="px-2.5 py-1.5 text-xs font-bold bg-card text-success hover:bg-success/15 disabled:opacity-40">ON</button>
<button type="button" disabled={!st.connected}
onClick={() => (isACOM ? ACOMSetPower(false) : SPESetPower(false)).catch(() => {})}
className="px-2.5 py-1.5 text-xs font-bold bg-card text-danger border-l border-success/70 hover:bg-danger/15 disabled:opacity-40">OFF</button>
</div>
{!isACOM && (
<div className="inline-flex rounded-lg overflow-hidden border border-border">
{(['L', 'M', 'H'] as const).map((lvl, i) => (
<button key={lvl} type="button" disabled={!st.connected}
onClick={() => SPESetPowerLevel(lvl).catch(() => {})}
className={cn('px-2.5 py-1.5 text-xs font-bold disabled:opacity-40', i > 0 && 'border-l border-border',
(st.power_level || '').trim().toUpperCase() === lvl ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{lvl}
</button>
))}
</div>
)}
</div>
<div className="text-xs font-mono text-muted-foreground tabular-nums">
{st.connected
? <>
{isACOM ? (st.state || '') : (st.tx ? 'TX' : 'RX')}
{st.band ? ` · ${st.band}` : ''} · {outW}W · SWR {Number((isACOM ? st.swr : st.swr_ant) ?? 0).toFixed(1)} · {st.temp_c}°C
{isACOM && st.fan ? ` · Fan ${st.fan}` : ''}
</>
: (isACOM ? t('flxp.acomOffline') : t('flxp.speOffline'))}
</div>
{st.connected && (
<div>
<div className="h-2 rounded bg-muted overflow-hidden">
<div className={cn('h-full transition-all', frac > 0.9 ? 'bg-danger' : frac > 0.75 ? 'bg-warning' : 'bg-primary')}
style={{ width: `${Math.round(frac * 100)}%` }} />
</div>
<div className="text-[10px] text-muted-foreground mt-0.5">{t('flxp.outputPower')} {outW} W / {maxW} W</div>
</div>
)}
{(st.err_text || st.warnings || st.alarms) && (
<div className="text-[11px] font-bold text-danger"> {st.err_text || `${st.warnings || ''} ${st.alarms || ''}`}</div>
)}
</>
)}
</div>
</div>
);
}
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
const { t } = useI18n();
const [devices, setDevices] = useState<Device[]>([]);
@@ -260,6 +373,17 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
});
const dragId = useRef<string | null>(null);
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
// Amplifier (Settings → Amplifier): shown here so operators without a
// FlexRadio panel still get the controls. Re-read every 5s so enabling the
// amp in Settings makes the widget appear without reopening the tab.
const [amp, setAmp] = useState<{ enabled: boolean; type: string }>({ enabled: false, type: 'pgxl' });
useEffect(() => {
let alive = true;
const load = () => GetPGXLSettings().then((s: any) => { if (alive) setAmp({ enabled: !!s?.enabled, type: s?.type || 'pgxl' }); }).catch(() => {});
load();
const id = window.setInterval(load, 5000);
return () => { alive = false; window.clearInterval(id); };
}, []);
const loadDevices = useCallback(async () => {
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
@@ -380,13 +504,16 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
if (ant.enabled) {
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
}
if (amp.enabled) {
widgets.push({ id: 'amplifier', node: <AmplifierWidget ampType={amp.type} t={t} /> });
}
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
const widgetIds = ordered.map((w) => w.id);
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled;
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && !amp.enabled;
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
+4 -4
View File
@@ -211,7 +211,7 @@ const en: Dict = {
'autostart.hint': 'Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile.',
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
'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.',
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', '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.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', '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 a microHAM ARCO — no PstRotator needed. LAN: set Config → LAN → CONTROL PROTOCOL to 'Yaesu GS-232A' on the ARCO and enter the same TCP port here (up to 4 parallel connections). USB: set USB CONTROL PROTOCOL to 'Yaesu GS-232A' and pick the ARCO's COM port here (baud rate doesn't matter on USB).", '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 12 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.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
// CAT panel body
@@ -288,7 +288,7 @@ const en: Dict = {
// Misc panels/modals (alerts / send-spot / net / udp / filter / details)
'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close',
'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot',
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.logAll': 'Log everyone ({n})', 'ncp.logAllConfirm': 'Log all {n} on-air station(s) to the logbook?', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as <PST><FREQUENCY> whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save',
'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'QSL sent', 'fltb.fQslRcvd': 'QSL rcvd', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent', 'fltb.fLotwRcvd': 'LoTW rcvd', 'fltb.fEqslSent': 'eQSL sent', 'fltb.fEqslRcvd': 'eQSL rcvd', 'fltb.fQrzUpload': 'QRZ upload status', 'fltb.fClublogUpload': 'ClubLog upload status', 'fltb.fHrdlogUpload': 'HRDLog upload status', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'My callsign', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close',
'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ zone', 'detp.ituZone': 'ITU zone', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email',
@@ -514,7 +514,7 @@ const fr: Dict = {
'autostart.hint': "Lance des programmes externes (WSJT-X, JTAlert, contrôle rotator…) au démarrage d'OpsLog. Un programme déjà lancé n'est pas relancé. Enregistré par profil.",
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
'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.",
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', '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.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', '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 à un microHAM ARCO — sans PstRotator. Réseau : régler Config → LAN → CONTROL PROTOCOL sur « Yaesu GS-232A » sur l'ARCO et saisir ici le même port TCP (jusqu'à 4 connexions en parallèle). USB : régler USB CONTROL PROTOCOL sur « Yaesu GS-232A » et choisir ici le port COM de l'ARCO (la vitesse est sans importance en USB).", '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 12 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.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
@@ -583,7 +583,7 @@ const fr: Dict = {
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer',
'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot',
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Déjà contacté', 'ncp.wbHint': 'Cliquer une station (on air ou roster) pour voir les QSO précédents', 'ncp.wbNone': 'Aucun QSO précédent avec', 'ncp.wbFirst': 'premier', 'ncp.wbLast': 'dernier', 'ncp.wbResize': 'Glisser pour redimensionner', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Déjà contacté', 'ncp.wbHint': 'Cliquer une station (on air ou roster) pour voir les QSO précédents', 'ncp.wbNone': 'Aucun QSO précédent avec', 'ncp.wbFirst': 'premier', 'ncp.wbLast': 'dernier', 'ncp.wbResize': 'Glisser pour redimensionner', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.logAll': 'Logger tout le monde ({n})', 'ncp.logAllConfirm': 'Logger les {n} station(s) on air dans le logbook ?', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en <PST><FREQUENCY> à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer',
'fltb.fCallsign': 'Indicatif', 'fltb.fDate': 'Date / heure (UTC)', 'fltb.fEndDate': 'Date / heure de fin', 'fltb.fBand': 'Bande', 'fltb.fRxBand': 'Bande RX', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Sous-mode', 'fltb.fFreq': 'Fréquence (Hz)', 'fltb.fRxFreq': 'Fréquence RX (Hz)', 'fltb.fRstSent': 'RST envoyé', 'fltb.fRstRcvd': 'RST reçu', 'fltb.fName': 'Nom', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Adresse', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Locator', 'fltb.fCountry': 'Pays', 'fltb.fState': 'État', 'fltb.fCounty': 'Comté', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'Zone CQ', 'fltb.fItuz': 'Zone ITU', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'Réf SOTA', 'fltb.fPota': 'Réf POTA', 'fltb.fWwff': 'Réf WWFF', 'fltb.fRig': 'Station', 'fltb.fAntenna': 'Antenne', 'fltb.fQslSent': 'QSL envoyée', 'fltb.fQslRcvd': 'QSL reçue', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW envoyé', 'fltb.fLotwRcvd': 'LoTW reçu', 'fltb.fEqslSent': 'eQSL envoyé', 'fltb.fEqslRcvd': 'eQSL reçu', 'fltb.fQrzUpload': 'Statut upload QRZ', 'fltb.fClublogUpload': 'Statut upload ClubLog', 'fltb.fHrdlogUpload': 'Statut upload HRDLog', 'fltb.fContestId': 'ID contest', 'fltb.fSerialRcvd': 'Numéro reçu', 'fltb.fSerialSent': 'Numéro envoyé', 'fltb.fPropMode': 'Mode de propagation', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Mon indicatif', 'fltb.fOperator': 'Opérateur', 'fltb.fOwnerCallsign': 'Indicatif propriétaire', 'fltb.fMyGrid': 'Mon locator', 'fltb.fMyCountry': 'Mon pays', 'fltb.fMyState': 'Mon état', 'fltb.fMyCounty': 'Mon comté', 'fltb.fMyIota': 'Mon IOTA', 'fltb.fMySota': 'Ma réf SOTA', 'fltb.fMyPota': 'Ma réf POTA', 'fltb.fMyWwff': 'Ma réf WWFF', 'fltb.fMyStreet': 'Ma rue', 'fltb.fMyCity': 'Ma ville', 'fltb.fMyPostal': 'Mon code postal', 'fltb.fMyRig': 'Ma station', 'fltb.fMyAntenna': 'Mon antenne', 'fltb.fTxPower': 'Puissance TX (W)', 'fltb.fComment': 'Commentaire', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer',
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
+1 -1
View File
@@ -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.20.7';
export const APP_VERSION = '0.20.8';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -630,6 +630,8 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
export function PGXLSetFanMode(arg1:string):Promise<void>;
export function PGXLSetOperate(arg1:boolean):Promise<void>;
export function PickADIFMonitorFile():Promise<string>;
export function PickAudioFolder():Promise<string>;
+4
View File
@@ -1214,6 +1214,10 @@ export function PGXLSetFanMode(arg1) {
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
}
export function PGXLSetOperate(arg1) {
return window['go']['main']['App']['PGXLSetOperate'](arg1);
}
export function PickADIFMonitorFile() {
return window['go']['main']['App']['PickADIFMonitorFile']();
}
+6
View File
@@ -2583,6 +2583,8 @@ export namespace main {
port: number;
has_elevation: boolean;
rotator_num: number;
transport: string;
com_port: string;
static createFrom(source: any = {}) {
return new RotatorSettings(source);
@@ -2596,6 +2598,8 @@ export namespace main {
this.port = source["port"];
this.has_elevation = source["has_elevation"];
this.rotator_num = source["rotator_num"];
this.transport = source["transport"];
this.com_port = source["com_port"];
}
}
export class SecretStatus {
@@ -3208,6 +3212,7 @@ export namespace powergenius {
state?: string;
fan_mode?: string;
temperature: number;
operate: boolean;
static createFrom(source: any = {}) {
return new Status(source);
@@ -3221,6 +3226,7 @@ export namespace powergenius {
this.state = source["state"];
this.fan_mode = source["fan_mode"];
this.temperature = source["temperature"];
this.operate = source["operate"];
}
}
+11 -1
View File
@@ -33,6 +33,7 @@ type Status struct {
State string `json:"state,omitempty"` // IDLE / TRANSMIT_A …
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
Temperature float64 `json:"temperature"`
Operate bool `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it)
}
type Client struct {
@@ -119,9 +120,16 @@ func (c *Client) SetOperate(on bool) error {
if on {
v = "1"
}
_, err := c.command("operate=" + v)
if _, err := c.command("operate=" + v); err != nil {
return err
}
// Optimistic: the status poll's "operate" field (when the firmware reports
// one) confirms or corrects this.
c.statusMu.Lock()
c.status.Operate = on
c.statusMu.Unlock()
return nil
}
func (c *Client) pollLoop() {
t := time.NewTicker(pollEvery)
@@ -223,6 +231,8 @@ func (c *Client) parse(resp string) {
switch kv[0] {
case "state":
c.status.State = kv[1]
case "operate":
c.status.Operate = kv[1] == "1"
case "fanmode":
dev := strings.ToUpper(kv[1])
// Honour a recent optimistic change until the amp confirms it.
+143
View File
@@ -0,0 +1,143 @@
// Package gs232 drives rotator controllers that speak the Yaesu GS-232A
// protocol, over a raw TCP socket or a serial COM port. The target device is
// the microHAM ARCO: both its LAN "CONTROL PROTOCOL" setting (a TCP port) and
// its USB port ("USB CONTROL PROTOCOL", a virtual COM where the baud rate is
// irrelevant) can be set to speak Yaesu GS-232A — so OpsLog controls it
// directly, no PstRotator in between. ARCO accepts up to four parallel LAN
// connections, and commands are single CR-terminated lines, so short
// per-call connections (same idiom as the other rotator backends) work fine.
//
// GS-232A subset used:
//
// Maaa<CR> move to azimuth aaa (000-450)
// S<CR> stop rotation
// C<CR> query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B
// flavour); both are parsed.
package gs232
import (
"fmt"
"io"
"net"
"regexp"
"strconv"
"strings"
"time"
"go.bug.st/serial"
)
const (
dialTimeout = 3 * time.Second
ioTimeout = 2 * time.Second
)
// Client is a stateless per-call sender, mirroring the pst/rotgenius idiom.
// Exactly one of (Host, Port) or ComPort is used, per Transport.
type Client struct {
Host string
Port int
ComPort string // serial transport: "COM5" etc. (ARCO USB virtual COM — any baud)
}
// New returns a TCP Client with sane defaults applied for empty fields. There
// is no standard port: the number is whatever the user typed into the ARCO's
// LAN CONTROL PROTOCOL setting — 4001 is only a placeholder.
func New(host string, port int) *Client {
if host == "" {
host = "127.0.0.1"
}
if port <= 0 || port > 65535 {
port = 4001
}
return &Client{Host: host, Port: port}
}
// NewSerial returns a Client talking over the ARCO's USB virtual COM port. The
// baud rate is irrelevant on USB per the ARCO manual (8N1 framing matters); we
// open at 9600 which also suits a real RS-232 hookup left at its default.
func NewSerial(comPort string) *Client {
return &Client{ComPort: comPort}
}
// roundTrip opens a connection (TCP or serial per the client's config), sends
// one CR-terminated command and (when wantReply) reads one CR/LF-terminated
// reply line.
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
var conn io.ReadWriteCloser
if c.ComPort != "" {
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: 9600})
if err != nil {
return "", fmt.Errorf("open ARCO %s: %w", c.ComPort, err)
}
_ = sp.SetReadTimeout(200 * time.Millisecond)
conn = sp
} else {
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
if err != nil {
return "", fmt.Errorf("connect ARCO %s:%d: %w", c.Host, c.Port, err)
}
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
conn = nc
}
defer conn.Close()
if _, err := conn.Write([]byte(cmd + "\r")); err != nil {
return "", fmt.Errorf("send %q: %w", cmd, err)
}
if !wantReply {
return "", nil
}
buf := make([]byte, 64)
var sb strings.Builder
deadline := time.Now().Add(ioTimeout)
for time.Now().Before(deadline) {
n, err := conn.Read(buf)
if n > 0 {
sb.Write(buf[:n])
if strings.ContainsAny(sb.String(), "\r\n") {
break
}
}
// A serial read that times out returns (0, nil) — keep polling until the
// overall deadline; a real error ends the read.
if err != nil {
break
}
}
line := strings.TrimSpace(sb.String())
if line == "" {
return "", fmt.Errorf("no reply to %q", cmd)
}
return line, nil
}
// GoTo points the antenna at the given azimuth (0-359). GS-232A takes M000-M450
// (overlap rotators accept >360); we normalise to [0,360).
func (c *Client) GoTo(az int) error {
az = ((az % 360) + 360) % 360
_, err := c.roundTrip(fmt.Sprintf("M%03d", az), false)
return err
}
// Stop interrupts any in-progress rotation.
func (c *Client) Stop() error {
_, err := c.roundTrip("S", false)
return err
}
// azRe matches both reply flavours: "+0aaa" (GS-232A) and "AZ=aaa" (GS-232B).
var azRe = regexp.MustCompile(`(?:\+0|AZ=)(\d{3})`)
// Heading queries the current azimuth. Returns the raw reply for diagnostics.
func (c *Client) Heading() (az int, raw string, err error) {
raw, err = c.roundTrip("C", true)
if err != nil {
return 0, raw, err
}
m := azRe.FindStringSubmatch(raw)
if m == nil {
return 0, raw, fmt.Errorf("unrecognised azimuth reply %q", raw)
}
az, _ = strconv.Atoi(m[1])
return az % 360, raw, nil
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.20.7"
appVersion = "0.20.8"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.