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:
@@ -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 '';
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user