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
+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)
}