From 6c2c1f106a2bd1852e20a274dd75cd62f5ab2f29 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Sat, 15 Aug 2026 01:39:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(udp):=20custom=20outbound=20messages=20?= =?UTF-8?q?=E2=80=94=20a=20trigger,=20a=20template,=20UDP=20or=20a=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app.go | 27 +++ changelog.json | 6 +- frontend/src/App.tsx | 9 +- .../src/components/UDPIntegrationsPanel.tsx | 142 ++++++++++++++- frontend/src/lib/i18n.tsx | 2 + frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + frontend/wailsjs/go/models.ts | 10 ++ .../db/migrations/0028_udp_custom_trigger.sql | 17 ++ internal/integrations/udp/config.go | 49 ++++- internal/integrations/udp/custom.go | 167 ++++++++++++++++++ internal/integrations/udp/custom_test.go | 64 +++++++ internal/integrations/udp/server.go | 5 + udptrigger.go | 148 ++++++++++++++++ 14 files changed, 638 insertions(+), 14 deletions(-) create mode 100644 internal/db/migrations/0028_udp_custom_trigger.sql create mode 100644 internal/integrations/udp/custom.go create mode 100644 internal/integrations/udp/custom_test.go create mode 100644 udptrigger.go 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 (
)} - {draft.direction === 'outbound' && ( + {draft.direction === 'outbound' && draft.service_type !== 'custom' && (
)} + {draft.service_type === 'custom' && ( + + )} +