feat(udp): custom outbound messages — a trigger, a template, UDP or a URL

The hard-coded emitters each speak one published format at one fixed moment.
This is the escape hatch, and it exists mainly for antenna switches: they are
driven by a URL, and the band change is the trigger they want.

Four triggers, each carrying its own field set: band change (the RADIO's band,
not the entry form — a switch follows the rig, not what is being typed), QSO
logged, rotator command and callsign lookup. The panel prints the fields the
selected trigger can fill, which is the point of the whole thing: a placeholder
the trigger does not carry renders as nothing, and without the list an operator
writes {freq} on a band change and has no way to learn why the switch never
moved.

Three things the panel cannot show, handled here:

  - Escaping. A URL needs every value percent-encoded; a UDP payload must not
    be touched. The first portable callsign, F4BPO/P, puts a path separator in
    the middle of a query string otherwise — it works on the bench and fails on
    the air.
  - Blocking. An HTTP call to a switch that is unplugged would otherwise sit
    for the operating system's timeout, on the path of a band change. It runs
    in the background with a 3 s limit.
  - Silence. A switch answering 404 after a firmware update fails exactly like
    success looks from here, so the status code is logged, throttled per row.

The rotator trigger hooks the COMMAND rather than the SP/LP buttons, so the
compass and a spot click fire it too, and the path ("SP"/"LP") is passed
through because an azimuth alone cannot say which was taken — 137° is short
path to one station and long to another.

Credentials in a URL are stored as typed. That is the operator's call, on the
grounds that this is LAN gear, and the hint in the panel says so.
This commit is contained in:
2026-08-15 01:39:16 +02:00
parent 03d5376d01
commit 6c2c1f106a
14 changed files with 638 additions and 14 deletions
+27
View File
@@ -1119,6 +1119,10 @@ func (a *App) startup(ctx context.Context) {
wruntime.EventsEmit(a.ctx, "cat:state", s)
}
a.emitRadioUDP(s)
// Custom outbound rows bound to a band change (antenna switches). The
// comparison against the last band lives in the helper, so this callback
// firing on every user-relevant change costs one string compare.
a.udpTriggerBandChange(s)
// Feed the frequency to any amplifier we are pretending to be a radio
// for. Just two atomic stores per amp — the reply itself is built when
// the amp polls, so a fast-tuning VFO costs nothing here.
@@ -2780,6 +2784,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
rec := adif.SingleRecordADIF(qc)
a.udp.EmitLoggedADIF(rec)
a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec)
a.udpTriggerQSOLogged(qc)
}
// Nudge the grid again so the award_refs columns appear once materialised.
if a.ctx != nil {
@@ -7162,6 +7167,13 @@ func (a *App) lookupCallsign(callsign string, force bool) (lookup.Result, error)
}
}
a.enrichFromULS(&r, callsign)
// Custom outbound rows bound to a lookup. After the enrichment, so a
// template sees the same grid and county the entry panel is about to show —
// two answers for one callsign is how a rotator ends up pointed elsewhere
// than the screen says.
if err == nil {
a.udpTriggerLookup(&r)
}
return r, err
}
@@ -12208,6 +12220,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
rec := adif.SingleRecordADIF(qc)
a.udp.EmitLoggedADIF(rec)
a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec)
a.udpTriggerQSOLogged(qc)
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qso:logged", id) // refresh again so award_refs show
@@ -14494,6 +14507,20 @@ func (a *App) GetRotatorHeading() RotatorHeading {
// RotatorGoTo points the active rotor at the given azimuth (and optional
// elevation if it is configured for it).
func (a *App) RotatorGoTo(az int, el int) error {
return a.RotatorGoToPath(az, el, "")
}
// RotatorGoToPath is RotatorGoTo with the path that produced the bearing —
// "SP", "LP", or "" when the caller has no opinion (the compass, a spot click).
//
// It exists for the custom outbound rows: an operator wanted the message to say
// WHICH bearing was taken, and the azimuth alone cannot tell you — 137° is short
// path to one station and long path to another.
func (a *App) RotatorGoToPath(az int, el int, path string) error {
// The trigger fires on the COMMAND, before any rotor is required: an
// operator may drive a switch or a display from the bearing without owning a
// rotator at all, and the early return below would swallow it.
a.udpTriggerRotator(az, el, strings.ToUpper(strings.TrimSpace(path)))
lr, _, _, ok := a.activeRotor()
if !ok {
return fmt.Errorf("no rotator configured")
+4 -2
View File
@@ -13,7 +13,8 @@
"Motorized antenna: when the controller settles somewhere other than where it was told, the log says so — that reads as “the antenna stopped responding” otherwise.",
"UDP: an outbound message now logs that it was sent, and a logged QSO with no outbound ADIF row configured says so once.",
"UDP: new outbound “WSJT-X logged QSO” — announces each contact on the WSJT-X UDP interface, both messages WSJT-X itself sends, for any logger that listens there rather than for plain-text ADIF (Logger32 among them).",
"Appearance: the Sahara theme is lighter, parchment rather than deep sand, with a terracotta accent."
"Appearance: the Sahara theme is lighter, parchment rather than deep sand, with a terracotta accent.",
"UDP: new outbound “Custom message” — you choose what fires it (band change, QSO logged, rotator command, callsign lookup) and what it says, with fields like {band} or {az}. It leaves as a UDP datagram or an HTTP request, which is how most antenna switches are driven."
],
"fr": [
"Envoi de spot : le commentaire porte désormais les références de diplôme après le mode — celles que vous avez attribuées (POTA, SOTA, IOTA…), pas le DXCC, la zone et le préfixe que chacun déduit de lindicatif. Un auto-spot porte VOS références dactivation.",
@@ -26,7 +27,8 @@
"Antenne motorisée : quand le contrôleur se fixe ailleurs que là où on la envoyé, le journal le dit — sans quoi cela ressemble à une antenne qui ne répond plus.",
"UDP : un message sortant inscrit désormais son envoi dans le journal, et un QSO enregistré sans ligne ADIF sortante le signale une fois.",
"UDP : nouvelle sortie « QSO enregistré WSJT-X » — annonce chaque contact sur linterface UDP WSJT-X, les deux messages que WSJT-X émet lui-même, pour tout logger qui écoute là plutôt que lADIF en texte brut (Logger32 entre autres).",
"Apparence : le thème Sahara s’éclaircit, parchemin plutôt que sable profond, avec un accent terre cuite."
"Apparence : le thème Sahara s’éclaircit, parchemin plutôt que sable profond, avec un accent terre cuite.",
"UDP : nouvelle sortie « Message personnalisé » — vous choisissez ce qui la déclenche (changement de bande, QSO enregistré, commande de rotor, recherche dindicatif) et ce quelle dit, avec des champs comme {band} ou {az}. Elle part en datagramme UDP ou en requête HTTP, ce qui est la façon dont se pilotent la plupart des commutateurs dantennes."
]
},
{
+6 -3
View File
@@ -17,6 +17,7 @@ import {
SMTPConfigured, SendLogToDeveloper,
WorkedBefore,
SetCompactMode, SetCompactHeight,
RotatorGoToPath,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
GetSecretStatus, UnlockSecrets,
RefreshCtyDat, DownloadAllReferenceLists,
@@ -5370,13 +5371,15 @@ export default function App() {
{(() => {
const p = dxPath;
const disabled = !p;
const goto = (az: number) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)));
// The path is passed through so a custom outbound row can say which
// bearing was taken — 137° is short path to one station and long to another.
const goto = (az: number, path: 'SP' | 'LP') => RotatorGoToPath(Math.round(az), -1, path).catch((err) => setError(String(err?.message ?? err)));
return (
<div className="inline-flex items-center rounded-full border border-info-border bg-info-muted overflow-hidden text-[11px] font-mono font-semibold">
<button
type="button"
disabled={disabled}
onClick={() => p && goto(p.bearingShort)}
onClick={() => p && goto(p.bearingShort, 'SP')}
title={p
? `Rotate short-path · ${Math.round(p.distanceShort).toLocaleString()} km`
: (station.my_grid ? 'No remote grid' : 'Set your station grid in Preferences')}
@@ -5393,7 +5396,7 @@ export default function App() {
<button
type="button"
disabled={disabled}
onClick={() => p && goto(p.bearingLong)}
onClick={() => p && goto(p.bearingLong, 'LP')}
title={p ? `Rotate long-path · ${Math.round(p.distanceLong).toLocaleString()} km` : ''}
className={cn(
'px-1.5 py-0.5 border-l border-info-border text-[10px] transition-colors',
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
import {
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
@@ -24,7 +24,13 @@ type UDPConfig = {
direction: 'inbound' | 'outbound';
name: string;
port: number;
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo' | 'wsjt_log';
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo' | 'wsjt_log' | 'custom';
// Custom rows only.
trigger?: string;
template?: string;
transport?: string;
url?: string;
line_end?: string;
multicast: boolean;
multicast_group: string;
destination_ip: string;
@@ -89,6 +95,16 @@ const SERVICE_TYPES: Array<{
hint: 'udpp.svcWsjtLogHint',
defaults: { port: 2250, destination_ip: '127.0.0.1' },
},
{
// The general case: the operator picks the moment and writes the message.
// Mainly for antenna switches, which are driven by a URL and want the band
// change — but nothing about it is specific to them.
id: 'custom',
direction: 'outbound',
label: 'udpp.svcCustomLabel',
hint: 'udpp.svcCustomHint',
defaults: { port: 12000, destination_ip: '127.0.0.1' },
},
{
id: 'pstrotator_freq',
direction: 'outbound',
@@ -105,6 +121,27 @@ const SERVICE_TYPES: Array<{
},
];
// The fields each trigger carries, and NOTHING else.
//
// This list is the point of the whole feature. A rotation has no frequency, a
// lookup has no report — and a placeholder the trigger cannot fill renders as
// nothing at all. Without seeing what is available, an operator writes {freq}
// on a band change, gets an empty string, and has no way to learn why. So the
// panel prints them under the box, for the trigger actually selected.
const TRIGGER_FIELDS: Record<string, string[]> = {
qso_logged: ['call', 'band', 'band_m', 'mode', 'freq_hz', 'freq_mhz', 'grid', 'name', 'country', 'dxcc', 'rst_s', 'rst_r', 'comment', 'date', 'time'],
rotator_goto: ['az', 'el', 'path'],
lookup_done: ['call', 'name', 'grid', 'country', 'qth', 'state', 'dxcc'],
band_change: ['band', 'band_m', 'freq_hz', 'freq_mhz', 'mode'],
};
const TRIGGERS = [
{ id: 'band_change', label: 'udpp.trgBand' },
{ id: 'qso_logged', label: 'udpp.trgQso' },
{ id: 'rotator_goto', label: 'udpp.trgRotator' },
{ id: 'lookup_done', label: 'udpp.trgLookup' },
];
type Props = { onError: (msg: string) => void };
export function UDPIntegrationsPanel({ onError }: Props) {
@@ -136,6 +173,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
destination_ip: preset.defaults.destination_ip ?? '',
enabled: true,
sort_order: items.filter((i) => i.direction === direction).length,
trigger: '', template: '', transport: 'udp', url: '', line_end: '',
});
}
@@ -388,7 +426,7 @@ function EditDialog({
</div>
)}
{draft.direction === 'outbound' && (
{draft.direction === 'outbound' && draft.service_type !== 'custom' && (
<div className="space-y-1">
<Label>{t('udpp.destinationIp')}</Label>
<Input
@@ -400,6 +438,10 @@ function EditDialog({
</div>
)}
{draft.service_type === 'custom' && (
<CustomFields draft={draft} setDraft={setDraft} />
)}
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={draft.enabled}
@@ -425,3 +467,97 @@ function EditDialog({
// silence unused-import for cn — kept for future styling tweaks
void cn;
// CustomFields is the editor for a custom outbound row: what fires it, how it
// leaves, and what it says.
function CustomFields({ draft, setDraft }: {
draft: UDPConfig;
setDraft: React.Dispatch<React.SetStateAction<UDPConfig>>;
}) {
const { t } = useI18n();
const fields = TRIGGER_FIELDS[draft.trigger ?? ''] ?? [];
const isURL = draft.transport === 'url';
return (
<div className="space-y-4 rounded-md border border-border/60 p-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('udpp.trigger')}</Label>
<Select value={draft.trigger || 'band_change'} onValueChange={(v) => setDraft((d) => ({ ...d, trigger: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{TRIGGERS.map((x) => <SelectItem key={x.id} value={x.id}>{t(x.label)}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>{t('udpp.transport')}</Label>
<Select value={draft.transport || 'udp'} onValueChange={(v) => setDraft((d) => ({ ...d, transport: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="udp">{t('udpp.transportUdp')}</SelectItem>
<SelectItem value="url">{t('udpp.transportUrl')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{isURL ? (
<div className="space-y-1">
<Label>{t('udpp.url')}</Label>
<Input
className="font-mono text-xs"
placeholder="http://192.168.1.50/antenna?band={band}"
value={draft.url ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, url: e.target.value }))}
/>
<div className="text-[10px] text-muted-foreground">{t('udpp.urlHint')}</div>
</div>
) : (
<>
<div className="grid grid-cols-[1fr_auto] gap-2">
<div className="space-y-1">
<Label>{t('udpp.destinationIp')}</Label>
<Input
className="font-mono"
placeholder="127.0.0.1"
value={draft.destination_ip}
onChange={(e) => setDraft((d) => ({ ...d, destination_ip: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label>{t('udpp.lineEnd')}</Label>
<Select value={draft.line_end || 'none'} onValueChange={(v) => setDraft((d) => ({ ...d, line_end: v === 'none' ? '' : v }))}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('udpp.lineEndNone')}</SelectItem>
<SelectItem value="lf">LF</SelectItem>
<SelectItem value="crlf">CRLF</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label>{t('udpp.template')}</Label>
<textarea
className="w-full min-h-[64px] rounded-md border border-input bg-background px-2 py-1.5 font-mono text-xs"
placeholder="<AZIMUT>{az}</AZIMUT>"
value={draft.template ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, template: e.target.value }))}
/>
</div>
</>
)}
{/* What this trigger can actually fill in. */}
<div className="space-y-1">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('udpp.fieldsAvailable')}</div>
<div className="flex flex-wrap gap-1">
{fields.map((f) => (
<code key={f} className="rounded bg-muted px-1 py-px text-[10px] text-muted-foreground">{`{${f}}`}</code>
))}
</div>
<div className="text-[10px] text-muted-foreground">{t('udpp.fieldsHint')}</div>
</div>
</div>
);
}
+2
View File
@@ -381,6 +381,7 @@ const en: Dict = {
'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': 'mic-pass order · ⬆⬇ to reorder · double-click → edit · "Log & end" to save', 'ncp.moveUp': 'Move up the mic-pass order', 'ncp.moveDown': 'Move down the mic-pass order', '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.svcCustomLabel': 'Custom message', 'udpp.svcCustomHint': 'You choose what fires it and what it says. A UDP datagram or an HTTP request — the latter is how most antenna switches are driven.', 'udpp.trigger': 'Fires on', 'udpp.trgBand': 'Band change (radio)', 'udpp.trgQso': 'QSO logged', 'udpp.trgRotator': 'Rotator command', 'udpp.trgLookup': 'Callsign lookup', 'udpp.transport': 'Sends as', 'udpp.transportUdp': 'UDP message', 'udpp.transportUrl': 'URL (HTTP GET)', 'udpp.url': 'URL', 'udpp.urlHint': 'Values are URL-encoded. Credentials may be included as http://user:pass@host/… — stored as typed, so keep it to your own network.', 'udpp.template': 'Message', 'udpp.lineEnd': 'Line end', 'udpp.lineEndNone': 'None', 'udpp.fieldsAvailable': 'Fields for this trigger', 'udpp.fieldsHint': 'Anything else renders empty.',
'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.svcWsjtLogLabel': 'WSJT-X logged QSO', 'udpp.svcWsjtLogHint': 'Announces each logged QSO on the WSJT-X UDP interface — both messages WSJT-X itself sends. For any logger that listens there rather than for plain-text ADIF (Logger32s additional UDP sockets, for one).', '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': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslSentVia': 'QSL sent via', 'fltb.fQslRcvdVia': 'QSL rcvd via', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', '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.opIn': 'is one of', 'fltb.opNotIn': 'is none of', 'fltb.listPh': '2m, 70cm — comma separated', '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.newCounty': 'NEW', 'detp.newCountyTip': 'County never worked before', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ', 'detp.ituZone': 'ITU', '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 (manager)', 'detp.detected': 'Detected — this contact will count for:', 'detp.ambiguous': 'Ambiguous — pick one:', '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', 'detp.contactedWeb': 'Website',
@@ -793,6 +794,7 @@ const fr: Dict = {
'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': 'ordre de passage du micro · ⬆⬇ pour réordonner · double-clic → éditer · « Logger & terminer »', 'ncp.moveUp': "Monter dans l'ordre de passage", 'ncp.moveDown': "Descendre dans l'ordre de passage", '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.svcCustomLabel': 'Message personnalisé', 'udpp.svcCustomHint': 'Vous choisissez ce qui le déclenche et ce quil dit. Datagramme UDP ou requête HTTP — cette dernière est la façon dont se pilotent la plupart des commutateurs dantennes.', 'udpp.trigger': 'Déclencheur', 'udpp.trgBand': 'Changement de bande (radio)', 'udpp.trgQso': 'QSO enregistré', 'udpp.trgRotator': 'Commande de rotor', 'udpp.trgLookup': 'Recherche dindicatif', 'udpp.transport': 'Envoi', 'udpp.transportUdp': 'Message UDP', 'udpp.transportUrl': 'URL (HTTP GET)', 'udpp.url': 'URL', 'udpp.urlHint': 'Les valeurs sont encodées pour lURL. Les identifiants peuvent s’écrire http://user:pass@hôte/… — stockés tels quels, à réserver à votre réseau local.', 'udpp.template': 'Message', 'udpp.lineEnd': 'Fin de ligne', 'udpp.lineEndNone': 'Aucune', 'udpp.fieldsAvailable': 'Champs de ce déclencheur', 'udpp.fieldsHint': 'Tout autre champ rendra du vide.',
'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.svcWsjtLogLabel': 'QSO enregistré WSJT-X', 'udpp.svcWsjtLogHint': 'Annonce chaque QSO enregistré sur linterface UDP WSJT-X — les deux messages que WSJT-X émet lui-même. Pour tout logger qui écoute là plutôt que lADIF en texte brut (les sockets UDP supplémentaires de Logger32, par exemple).', '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': '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': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslSentVia': 'QSL envoyée via', 'fltb.fQslRcvdVia': 'QSL reçue via', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', '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': 'é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.opIn': 'est parmi', 'fltb.opNotIn': 'n est pas parmi', 'fltb.listPh': '2m, 70cm — séparés par des virgules', '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.newCounty': 'NOUV', 'detp.newCountyTip': 'Comté jamais contacté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'CQ', 'detp.ituZone': '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 (manager)', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.ambiguous': 'Ambigu — choisissez :', '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', 'detp.contactedWeb': 'Site web',
+2
View File
@@ -876,6 +876,8 @@ export function RevealDataFolder():Promise<void>;
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
export function RotatorGoToPath(arg1:number,arg2:number,arg3:string):Promise<void>;
export function RotatorPark():Promise<void>;
export function RotatorStop():Promise<void>;
+4
View File
@@ -1694,6 +1694,10 @@ export function RotatorGoTo(arg1, arg2) {
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
}
export function RotatorGoToPath(arg1, arg2, arg3) {
return window['go']['main']['App']['RotatorGoToPath'](arg1, arg2, arg3);
}
export function RotatorPark() {
return window['go']['main']['App']['RotatorPark']();
}
+10
View File
@@ -5143,6 +5143,11 @@ export namespace udp {
destination_ip: string;
enabled: boolean;
sort_order: number;
trigger: string;
template: string;
transport: string;
url: string;
line_end: string;
static createFrom(source: any = {}) {
return new Config(source);
@@ -5160,6 +5165,11 @@ export namespace udp {
this.destination_ip = source["destination_ip"];
this.enabled = source["enabled"];
this.sort_order = source["sort_order"];
this.trigger = source["trigger"];
this.template = source["template"];
this.transport = source["transport"];
this.url = source["url"];
this.line_end = source["line_end"];
}
}
@@ -0,0 +1,17 @@
-- Custom outbound messages: a trigger, a template, and a transport.
--
-- Until now an outbound row was one of a handful of hard-coded emitters, each
-- with its own format and its own moment. This adds the general case: pick what
-- fires it, write what it says, and choose whether it leaves as a UDP datagram
-- or an HTTP request.
--
-- The HTTP half is what makes it useful for antenna switches — most of them are
-- driven by a URL like http://192.168.1.50/antenna?band=20m — and the band
-- change is the trigger they want, being rare by nature.
--
-- Empty on every existing row, which is what the hard-coded services expect.
ALTER TABLE integrations_udp ADD COLUMN trig TEXT NOT NULL DEFAULT '';
ALTER TABLE integrations_udp ADD COLUMN template TEXT NOT NULL DEFAULT '';
ALTER TABLE integrations_udp ADD COLUMN transport TEXT NOT NULL DEFAULT '';
ALTER TABLE integrations_udp ADD COLUMN url TEXT NOT NULL DEFAULT '';
ALTER TABLE integrations_udp ADD COLUMN line_end TEXT NOT NULL DEFAULT '';
+43 -6
View File
@@ -41,6 +41,30 @@ const (
// Logger32 and others listen on the WSJT-X interface, not for plain text, and
// discard a bare ADIF record without a word — so the two cannot be one row.
ServiceWSJTLog ServiceType = "wsjt_log"
// ServiceCustom is the general case: the operator picks the trigger, writes
// the message, and chooses UDP or HTTP. See Trigger and Config.Template.
ServiceCustom ServiceType = "custom"
)
// Trigger names the moment a custom outbound row fires.
type Trigger string
const (
TriggerQSOLogged Trigger = "qso_logged"
TriggerRotatorGo Trigger = "rotator_goto"
TriggerLookupDone Trigger = "lookup_done"
// TriggerBandChange is the RIG changing band, not the entry form: a switch
// follows the radio, not what is being typed. Rare by nature, so unlike a
// frequency trigger it needs no damping.
TriggerBandChange Trigger = "band_change"
)
// Transport is how a custom row leaves: a UDP datagram or an HTTP GET.
type Transport string
const (
TransportUDP Transport = "udp"
TransportURL Transport = "url"
)
// Config is one user-defined UDP connection.
@@ -55,6 +79,13 @@ type Config struct {
DestinationIP string `json:"destination_ip"` // outbound only
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
// Custom rows only (ServiceCustom).
Trigger Trigger `json:"trigger"`
Template string `json:"template"` // message text with {fields}
Transport Transport `json:"transport"` // udp | url
URL string `json:"url"` // URL template, when transport is url
LineEnd string `json:"line_end"` // "" | lf | crlf — UDP only
}
// Repo is the persistence layer for UDP integration rows.
@@ -66,7 +97,8 @@ func (r *Repo) List(ctx context.Context) ([]Config, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, direction, name, port, service_type,
multicast, multicast_group, destination_ip,
enabled, sort_order
enabled, sort_order,
trig, template, transport, url, line_end
FROM integrations_udp
ORDER BY direction, sort_order, id`)
if err != nil {
@@ -78,7 +110,8 @@ func (r *Repo) List(ctx context.Context) ([]Config, error) {
var c Config
var mc, en int
if err := rows.Scan(&c.ID, &c.Direction, &c.Name, &c.Port, &c.ServiceType,
&mc, &c.MulticastGroup, &c.DestinationIP, &en, &c.SortOrder); err != nil {
&mc, &c.MulticastGroup, &c.DestinationIP, &en, &c.SortOrder,
&c.Trigger, &c.Template, &c.Transport, &c.URL, &c.LineEnd); err != nil {
return nil, err
}
c.Multicast = mc != 0
@@ -105,10 +138,12 @@ func (r *Repo) Save(ctx context.Context, c *Config) error {
if c.ID == 0 {
res, err := r.db.ExecContext(ctx, `
INSERT INTO integrations_udp(direction, name, port, service_type,
multicast, multicast_group, destination_ip, enabled, sort_order)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`,
multicast, multicast_group, destination_ip, enabled, sort_order,
trig, template, transport, url, line_end)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.Direction, c.Name, c.Port, c.ServiceType,
mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder)
mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder,
c.Trigger, c.Template, c.Transport, c.URL, c.LineEnd)
if err != nil {
return fmt.Errorf("insert udp: %w", err)
}
@@ -121,10 +156,12 @@ func (r *Repo) Save(ctx context.Context, c *Config) error {
direction = ?, name = ?, port = ?, service_type = ?,
multicast = ?, multicast_group = ?, destination_ip = ?,
enabled = ?, sort_order = ?,
trig = ?, template = ?, transport = ?, url = ?, line_end = ?,
updated_at = ?
WHERE id = ?`,
c.Direction, c.Name, c.Port, c.ServiceType,
mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder, db.NowISO(), c.ID)
mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder,
c.Trigger, c.Template, c.Transport, c.URL, c.LineEnd, db.NowISO(), c.ID)
if err != nil {
return fmt.Errorf("update udp: %w", err)
}
+167
View File
@@ -0,0 +1,167 @@
package udp
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"hamlog/internal/applog"
)
// Custom outbound messages: a trigger, a template, a transport.
//
// The hard-coded emitters beside this one each speak a published format at a
// fixed moment. This is the escape hatch: the operator chooses when it fires and
// writes what it says. It exists mainly for antenna switches, which are driven
// by a URL — http://192.168.1.50/antenna?band=20m — and want the band change,
// but nothing here is specific to them.
//
// The presets are NOT reimplemented on top of this. PstRotator and N1MM
// RadioInfo are documented formats that work; a typo in a template would break
// them silently, and "it worked before I touched anything" is not a trade worth
// making for one less code path.
// httpTimeout bounds a request. A UDP datagram leaves and is forgotten; an HTTP
// call to a switch that has been unplugged will otherwise sit there for the
// operating system's own timeout — and this fires on a band change, which is
// exactly when nobody wants to wait.
const httpTimeout = 3 * time.Second
// customHTTP is the client for URL rows. One client, reused: a new one per
// request leaks connections on a switch that keeps them alive.
var customHTTP = &http.Client{Timeout: httpTimeout}
// Render substitutes {field} placeholders in tmpl.
//
// escape decides how a value is written, and it is not cosmetic: in a URL every
// value has to be percent-encoded, or the first portable callsign — F4BPO/P —
// puts a path separator in the middle of a query string, and a name with a
// space breaks the request outright. In a UDP payload the opposite holds: the
// receiver wants the text as typed.
//
// A placeholder with no value becomes empty rather than staying as "{freq}".
// Leaving the braces would send them down the wire, and a receiver would store
// the literal text as if it meant something.
func Render(tmpl string, fields map[string]string, escape bool) string {
var b strings.Builder
for {
i := strings.IndexByte(tmpl, '{')
if i < 0 {
b.WriteString(tmpl)
return b.String()
}
j := strings.IndexByte(tmpl[i:], '}')
if j < 0 {
b.WriteString(tmpl) // an unclosed brace is literal text
return b.String()
}
b.WriteString(tmpl[:i])
key := strings.ToLower(strings.TrimSpace(tmpl[i+1 : i+j]))
v := fields[key]
if escape {
v = url.QueryEscape(v)
}
b.WriteString(v)
tmpl = tmpl[i+j+1:]
}
}
// lineEnding turns the stored choice into the bytes appended to a UDP payload.
// Many receivers read a line at a time and wait for a terminator; a trailing
// space in a text box is invisible and lost on the first copy-paste, so this is
// a choice rather than something to type.
func lineEnding(s string) string {
switch strings.ToLower(strings.TrimSpace(s)) {
case "lf":
return "\n"
case "crlf":
return "\r\n"
}
return ""
}
// EmitTrigger fires every enabled custom row bound to this trigger.
//
// Runs the sends in the background: an HTTP request must never sit on the path
// of the thing that caused it — a band change waiting on an antenna switch is a
// band change the operator watches not happen.
func (m *Manager) EmitTrigger(t Trigger, fields map[string]string) {
var rows []Config
for _, c := range m.Outbound(ServiceCustom) {
if c.Trigger == t {
rows = append(rows, c)
}
}
if len(rows) == 0 {
return
}
for _, c := range rows {
go m.fireCustom(c, fields)
}
}
func (m *Manager) fireCustom(c Config, fields map[string]string) {
if c.Transport == TransportURL {
target := Render(c.URL, fields, true)
if strings.TrimSpace(target) == "" {
return
}
m.getURL(c, target)
return
}
payload := Render(c.Template, fields, false) + lineEnding(c.LineEnd)
if payload == "" {
return
}
m.sendTo(c, []byte(payload))
}
// getURL performs the request and reports what came back.
//
// The status code is logged, not just transport errors. A switch answering 404
// because a firmware update moved its endpoint is a total failure that looks
// exactly like success from here — the datagram equivalent does not exist, and
// an operator would have no way to tell.
func (m *Manager) getURL(c Config, target string) {
ctx, cancel := context.WithTimeout(context.Background(), httpTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
applog.Printf("udp: [%s] bad URL %q: %v", c.Name, target, err)
return
}
resp, err := customHTTP.Do(req)
if err != nil {
m.noteCustomHTTP(c, fmt.Sprintf("%s failed: %v", target, err))
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
m.noteCustomHTTP(c, fmt.Sprintf("%s answered %s", target, resp.Status))
return
}
applog.Printf("udp: [%s] GET %s → %s", c.Name, target, resp.Status)
}
// noteCustomHTTP logs a failing URL row, throttled per row: a switch that is
// off answers the same way on every band change, and this fires on every one.
func (m *Manager) noteCustomHTTP(c Config, msg string) {
m.httpFailMu.Lock()
if m.httpFailAt == nil {
m.httpFailAt = map[int64]time.Time{}
}
last := m.httpFailAt[c.ID]
now := time.Now()
quiet := now.Sub(last) < 5*time.Minute
if !quiet {
m.httpFailAt[c.ID] = now
}
m.httpFailMu.Unlock()
if quiet {
return
}
applog.Printf("udp: [%s] %s", c.Name, msg)
}
+64
View File
@@ -0,0 +1,64 @@
package udp
import "testing"
// The escaping rule is the whole difference between a template that works on
// the bench and one that fails on the first portable callsign.
func TestRenderEscapesForURLsOnly(t *testing.T) {
fields := map[string]string{
"call": "F4BPO/P", "name": "Jean Pierre", "band": "20m", "az": "137",
}
// UDP: the receiver wants the text as typed.
if got := Render("<CALL>{call}</CALL>", fields, false); got != "<CALL>F4BPO/P</CALL>" {
t.Errorf("UDP render = %q", got)
}
// URL: a slash in a callsign is a path separator, a space breaks the request.
if got := Render("http://sw/a?c={call}&n={name}", fields, true); got != "http://sw/a?c=F4BPO%2FP&n=Jean+Pierre" {
t.Errorf("URL render = %q", got)
}
}
// A placeholder the trigger does not carry becomes empty, not the literal
// "{freq}" — otherwise the braces go down the wire and a receiver files them.
func TestRenderDropsUnknownFields(t *testing.T) {
if got := Render("<AZIMUT>{az}</AZIMUT>{freq}", map[string]string{"az": "137"}, false); got != "<AZIMUT>137</AZIMUT>" {
t.Errorf("got %q", got)
}
}
// Text that merely looks like a placeholder must survive: an unclosed brace is
// content, not a broken field.
func TestRenderLeavesLiteralBraces(t *testing.T) {
for _, c := range []struct{ in, want string }{
{"no fields here", "no fields here"},
{"{unclosed", "{unclosed"},
{"a } b", "a } b"},
} {
if got := Render(c.in, map[string]string{}, false); got != c.want {
t.Errorf("Render(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// Field names are matched case- and space-insensitively: an operator typing
// {Band} or { band } means the band.
func TestRenderFieldNamesAreForgiving(t *testing.T) {
f := map[string]string{"band": "20m"}
for _, tmpl := range []string{"{band}", "{Band}", "{ BAND }"} {
if got := Render(tmpl, f, false); got != "20m" {
t.Errorf("Render(%q) = %q, want 20m", tmpl, got)
}
}
}
// Receivers that read a line at a time need a terminator, and a trailing space
// in a text box is invisible — hence a choice rather than something to type.
func TestLineEnding(t *testing.T) {
for in, want := range map[string]string{
"": "", "lf": "\n", "LF": "\n", "crlf": "\r\n", " CRLF ": "\r\n", "nonsense": "",
} {
if got := lineEnding(in); got != want {
t.Errorf("lineEnding(%q) = %q, want %q", in, got, want)
}
}
}
+5
View File
@@ -501,6 +501,11 @@ type Manager struct {
// rather than one per QSO logged.
noADIFOnce sync.Once
// httpFailAt throttles the complaint from a custom URL row, per row: a
// switch that is off answers the same way on every band change.
httpFailMu sync.Mutex
httpFailAt map[int64]time.Time
mu sync.Mutex
inbound map[int64]*Server
outbound []Config
+148
View File
@@ -0,0 +1,148 @@
package main
// Wiring the custom outbound UDP/URL rows to the moments that fire them.
//
// One function per trigger, each building the fields its template can use. The
// field SET is per trigger and that is the point: a rotation has no frequency, a
// lookup has no report. The Settings panel lists what each one offers, because a
// template that quietly renders an empty string is the worst kind of wrong.
import (
"fmt"
"strconv"
"strings"
"sync"
"hamlog/internal/cat"
"hamlog/internal/integrations/udp"
"hamlog/internal/lookup"
"hamlog/internal/qso"
)
// lastTriggerBand remembers the rig's band so a change can be told from a
// frequency moving inside one. Guarded because the CAT callback and a settings
// reload can both reach it.
var (
lastTriggerBandMu sync.Mutex
lastTriggerBand string
)
// udpTriggerQSOLogged fires after a contact is written to the log.
func (a *App) udpTriggerQSOLogged(q qso.QSO) {
if a.udp == nil {
return
}
f := map[string]string{
"call": q.Callsign,
"band": q.Band,
"band_m": bandMetres(q.Band),
"mode": q.Mode,
"grid": q.Grid,
"name": q.Name,
"country": q.Country,
"rst_s": q.RSTSent,
"rst_r": q.RSTRcvd,
"comment": q.Comment,
"date": q.QSODate.UTC().Format("20060102"),
"time": q.QSODate.UTC().Format("150405"),
}
if q.FreqHz != nil && *q.FreqHz > 0 {
f["freq_hz"] = strconv.FormatInt(*q.FreqHz, 10)
f["freq_mhz"] = fmt.Sprintf("%.6f", float64(*q.FreqHz)/1e6)
}
if q.DXCC != nil {
f["dxcc"] = strconv.Itoa(*q.DXCC)
}
a.udp.EmitTrigger(udp.TriggerQSOLogged, f)
}
// udpTriggerRotator fires whenever the antenna is told to turn — the header's
// SP and LP buttons, the compass, a click on a spot.
//
// Hooked to the COMMAND rather than to a button: one place, and it stays true
// when the interface changes. path is "SP", "LP" or "" when the caller did not
// say which.
func (a *App) udpTriggerRotator(az, el int, path string) {
if a.udp == nil {
return
}
f := map[string]string{
"az": strconv.Itoa(az),
"path": path,
}
if el >= 0 {
f["el"] = strconv.Itoa(el)
}
a.udp.EmitTrigger(udp.TriggerRotatorGo, f)
}
// udpTriggerLookup fires after a callbook lookup returns.
func (a *App) udpTriggerLookup(r *lookup.Result) {
if a.udp == nil || r == nil {
return
}
a.udp.EmitTrigger(udp.TriggerLookupDone, map[string]string{
"call": r.Callsign,
"name": r.Name,
"grid": r.Grid,
"country": r.Country,
"qth": r.QTH,
"state": r.State,
"dxcc": strconv.Itoa(r.DXCC),
})
}
// udpTriggerBandChange fires when the RIG changes band — not the entry form: an
// antenna switch follows the radio, not what is being typed.
//
// Called from the CAT state callback, which runs on every user-relevant change,
// so the comparison against the last band is what makes this rare rather than
// continuous. No damping needed beyond that.
func (a *App) udpTriggerBandChange(s cat.RigState) {
if a.udp == nil {
return
}
band := strings.ToLower(strings.TrimSpace(s.Band))
if band == "" || !s.Connected {
return
}
lastTriggerBandMu.Lock()
changed := band != lastTriggerBand
if changed {
lastTriggerBand = band
}
lastTriggerBandMu.Unlock()
if !changed {
return
}
f := map[string]string{
"band": band,
"band_m": bandMetres(band),
"mode": s.Mode,
}
if s.FreqHz > 0 {
f["freq_hz"] = strconv.FormatInt(s.FreqHz, 10)
f["freq_mhz"] = fmt.Sprintf("%.6f", float64(s.FreqHz)/1e6)
}
a.udp.EmitTrigger(udp.TriggerBandChange, f)
}
// bandMetres turns "20m" into "20" — an antenna switch usually wants the number
// and nothing else, and asking an operator to strip it in a template would be
// one more thing to get wrong.
func bandMetres(band string) string {
b := strings.ToLower(strings.TrimSpace(band))
b = strings.TrimSuffix(b, "m")
b = strings.TrimSuffix(b, "c") // "70cm" → "70c" → "70"
if b == "" {
return ""
}
for _, r := range b {
if r < '0' || r > '9' {
if r != '.' {
return ""
}
}
}
return b
}