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")