Files
OpsLog/internal/integrations/udp/config.go
T
rouggy 6c2c1f106a 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.
2026-08-15 01:39:16 +02:00

175 lines
6.0 KiB
Go

// Package udp manages user-defined UDP integrations: inbound listeners
// (WSJT-X, JTDX, MSHV log events; JTAlert ADIF; N1MM XML; DXHunter call)
// and outbound emitters (db_updated → notifies Cloudlog/N1MM when HamLog
// just logged a QSO).
//
// One Server per connection row, started/stopped by the Manager when the
// user enables/disables or edits the row. Multicast support lets multiple
// apps share the same port without bind conflicts — essential since
// WSJT-X uses 2237 and several tools already listen there.
package udp
import (
"context"
"database/sql"
"fmt"
"hamlog/internal/db"
)
// Direction is "inbound" (we listen) or "outbound" (we emit).
type Direction string
const (
Inbound Direction = "inbound"
Outbound Direction = "outbound"
)
// ServiceType selects the parser/emitter for a connection.
type ServiceType string
const (
ServiceWSJT ServiceType = "wsjt" // WSJT-X / JTDX / MSHV binary (inbound)
ServiceADIF ServiceType = "adif" // text ADIF over UDP (inbound)
ServiceN1MM ServiceType = "n1mm" // N1MM Logger+ XML (inbound)
ServiceRemoteCall ServiceType = "remote_call" // plain text callsign (inbound)
// Outbound emitters.
ServiceDBUpdated ServiceType = "db_updated" // ADIF of each locally-logged QSO (on save)
ServicePstFreq ServiceType = "pstrotator_freq" // <PST><FREQUENCY> radio freq (on freq change)
ServiceN1MMRadio ServiceType = "n1mm_radioinfo" // N1MM RadioInfo XML: freq+mode (on freq/mode change)
// ServiceWSJTLog wraps the same ADIF in a WSJT-X "Logged ADIF" datagram.
// 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.
type Config struct {
ID int64 `json:"id"`
Direction Direction `json:"direction"`
Name string `json:"name"`
Port int `json:"port"`
ServiceType ServiceType `json:"service_type"`
Multicast bool `json:"multicast"`
MulticastGroup string `json:"multicast_group"`
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.
type Repo struct{ db *sql.DB }
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
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,
trig, template, transport, url, line_end
FROM integrations_udp
ORDER BY direction, sort_order, id`)
if err != nil {
return nil, fmt.Errorf("list udp: %w", err)
}
defer rows.Close()
var out []Config
for rows.Next() {
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,
&c.Trigger, &c.Template, &c.Transport, &c.URL, &c.LineEnd); err != nil {
return nil, err
}
c.Multicast = mc != 0
c.Enabled = en != 0
out = append(out, c)
}
return out, rows.Err()
}
func (r *Repo) Save(ctx context.Context, c *Config) error {
if c.Direction != Inbound && c.Direction != Outbound {
return fmt.Errorf("invalid direction %q", c.Direction)
}
if c.Name == "" {
return fmt.Errorf("name required")
}
mc, en := 0, 0
if c.Multicast {
mc = 1
}
if c.Enabled {
en = 1
}
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,
trig, template, transport, url, line_end)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.Direction, c.Name, c.Port, c.ServiceType,
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)
}
id, _ := res.LastInsertId()
c.ID = id
return nil
}
_, err := r.db.ExecContext(ctx, `
UPDATE integrations_udp SET
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,
c.Trigger, c.Template, c.Transport, c.URL, c.LineEnd, db.NowISO(), c.ID)
if err != nil {
return fmt.Errorf("update udp: %w", err)
}
return nil
}
func (r *Repo) Delete(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM integrations_udp WHERE id = ?`, id)
return err
}