diff --git a/app.go b/app.go
index cfc6b14..04e5aa1 100644
--- a/app.go
+++ b/app.go
@@ -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")
diff --git a/changelog.json b/changelog.json
index 351c477..3442dba 100644
--- a/changelog.json
+++ b/changelog.json
@@ -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 l’indicatif. Un auto-spot porte VOS références d’activation.",
@@ -26,7 +27,8 @@
"Antenne motorisée : quand le contrôleur se fixe ailleurs que là où on l’a 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 l’interface UDP WSJT-X, les deux messages que WSJT-X émet lui-même, pour tout logger qui écoute là plutôt que l’ADIF 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 d’indicatif) et ce qu’elle 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 d’antennes."
]
},
{
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 3e1bedf..83fa68f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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 (
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() {
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',
diff --git a/frontend/src/components/UDPIntegrationsPanel.tsx b/frontend/src/components/UDPIntegrationsPanel.tsx
index 8c4d64c..e975b94 100644
--- a/frontend/src/components/UDPIntegrationsPanel.tsx
+++ b/frontend/src/components/UDPIntegrationsPanel.tsx
@@ -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 = {
+ 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({
)}
- {draft.direction === 'outbound' && (
+ {draft.direction === 'outbound' && draft.service_type !== 'custom' && (
{t('udpp.destinationIp')}
)}
+ {draft.service_type === 'custom' && (
+
+ )}
+
>;
+}) {
+ const { t } = useI18n();
+ const fields = TRIGGER_FIELDS[draft.trigger ?? ''] ?? [];
+ const isURL = draft.transport === 'url';
+ return (
+
+
+
+ {t('udpp.trigger')}
+ setDraft((d) => ({ ...d, trigger: v }))}>
+
+
+ {TRIGGERS.map((x) => {t(x.label)} )}
+
+
+
+
+ {t('udpp.transport')}
+ setDraft((d) => ({ ...d, transport: v }))}>
+
+
+ {t('udpp.transportUdp')}
+ {t('udpp.transportUrl')}
+
+
+
+
+
+ {isURL ? (
+
+
{t('udpp.url')}
+
setDraft((d) => ({ ...d, url: e.target.value }))}
+ />
+
{t('udpp.urlHint')}
+
+ ) : (
+ <>
+
+
+ {t('udpp.destinationIp')}
+ setDraft((d) => ({ ...d, destination_ip: e.target.value }))}
+ />
+
+
+ {t('udpp.lineEnd')}
+ setDraft((d) => ({ ...d, line_end: v === 'none' ? '' : v }))}>
+
+
+ {t('udpp.lineEndNone')}
+ LF
+ CRLF
+
+
+
+
+
+ {t('udpp.template')}
+
+ >
+ )}
+
+ {/* What this trigger can actually fill in. */}
+
+
{t('udpp.fieldsAvailable')}
+
+ {fields.map((f) => (
+ {`{${f}}`}
+ ))}
+
+
{t('udpp.fieldsHint')}
+
+
+ );
+}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx
index 6a9684a..86262c2 100644
--- a/frontend/src/lib/i18n.tsx
+++ b/frontend/src/lib/i18n.tsx
@@ -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 (Logger32’s 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 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 qu’il dit. Datagramme UDP ou requête HTTP — cette dernière est la façon dont se pilotent la plupart des commutateurs d’antennes.', 'udpp.trigger': 'Déclencheur', 'udpp.trgBand': 'Changement de bande (radio)', 'udpp.trgQso': 'QSO enregistré', 'udpp.trgRotator': 'Commande de rotor', 'udpp.trgLookup': 'Recherche d’indicatif', 'udpp.transport': 'Envoi', 'udpp.transportUdp': 'Message UDP', 'udpp.transportUrl': 'URL (HTTP GET)', 'udpp.url': 'URL', 'udpp.urlHint': 'Les valeurs sont encodées pour l’URL. 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 l’interface UDP WSJT-X — les deux messages que WSJT-X émet lui-même. Pour tout logger qui écoute là plutôt que l’ADIF 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 à 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',
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts
index 6db8e0f..8ed92d6 100644
--- a/frontend/wailsjs/go/main/App.d.ts
+++ b/frontend/wailsjs/go/main/App.d.ts
@@ -876,6 +876,8 @@ export function RevealDataFolder():Promise;
export function RotatorGoTo(arg1:number,arg2:number):Promise;
+export function RotatorGoToPath(arg1:number,arg2:number,arg3:string):Promise;
+
export function RotatorPark():Promise;
export function RotatorStop():Promise;
diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js
index 801252c..d18d829 100644
--- a/frontend/wailsjs/go/main/App.js
+++ b/frontend/wailsjs/go/main/App.js
@@ -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']();
}
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index d2becb6..43c7936 100644
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -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"];
}
}
diff --git a/internal/db/migrations/0028_udp_custom_trigger.sql b/internal/db/migrations/0028_udp_custom_trigger.sql
new file mode 100644
index 0000000..3e21589
--- /dev/null
+++ b/internal/db/migrations/0028_udp_custom_trigger.sql
@@ -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 '';
diff --git a/internal/integrations/udp/config.go b/internal/integrations/udp/config.go
index 1032149..7c4bcac 100644
--- a/internal/integrations/udp/config.go
+++ b/internal/integrations/udp/config.go
@@ -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)
}
diff --git a/internal/integrations/udp/custom.go b/internal/integrations/udp/custom.go
new file mode 100644
index 0000000..36b5736
--- /dev/null
+++ b/internal/integrations/udp/custom.go
@@ -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)
+}
diff --git a/internal/integrations/udp/custom_test.go b/internal/integrations/udp/custom_test.go
new file mode 100644
index 0000000..e12f76f
--- /dev/null
+++ b/internal/integrations/udp/custom_test.go
@@ -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} ", fields, false); got != "F4BPO/P " {
+ 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("{az} {freq}", map[string]string{"az": "137"}, false); got != "137 " {
+ 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)
+ }
+ }
+}
diff --git a/internal/integrations/udp/server.go b/internal/integrations/udp/server.go
index 46c98c9..396e381 100644
--- a/internal/integrations/udp/server.go
+++ b/internal/integrations/udp/server.go
@@ -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
diff --git a/udptrigger.go b/udptrigger.go
new file mode 100644
index 0000000..6881106
--- /dev/null
+++ b/udptrigger.go
@@ -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
+}