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
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
import {
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
@@ -24,7 +24,13 @@ type UDPConfig = {
direction: 'inbound' | 'outbound';
name: string;
port: number;
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo' | 'wsjt_log';
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo' | 'wsjt_log' | 'custom';
// Custom rows only.
trigger?: string;
template?: string;
transport?: string;
url?: string;
line_end?: string;
multicast: boolean;
multicast_group: string;
destination_ip: string;
@@ -89,6 +95,16 @@ const SERVICE_TYPES: Array<{
hint: 'udpp.svcWsjtLogHint',
defaults: { port: 2250, destination_ip: '127.0.0.1' },
},
{
// The general case: the operator picks the moment and writes the message.
// Mainly for antenna switches, which are driven by a URL and want the band
// change — but nothing about it is specific to them.
id: 'custom',
direction: 'outbound',
label: 'udpp.svcCustomLabel',
hint: 'udpp.svcCustomHint',
defaults: { port: 12000, destination_ip: '127.0.0.1' },
},
{
id: 'pstrotator_freq',
direction: 'outbound',
@@ -105,6 +121,27 @@ const SERVICE_TYPES: Array<{
},
];
// The fields each trigger carries, and NOTHING else.
//
// This list is the point of the whole feature. A rotation has no frequency, a
// lookup has no report — and a placeholder the trigger cannot fill renders as
// nothing at all. Without seeing what is available, an operator writes {freq}
// on a band change, gets an empty string, and has no way to learn why. So the
// panel prints them under the box, for the trigger actually selected.
const TRIGGER_FIELDS: Record<string, string[]> = {
qso_logged: ['call', 'band', 'band_m', 'mode', 'freq_hz', 'freq_mhz', 'grid', 'name', 'country', 'dxcc', 'rst_s', 'rst_r', 'comment', 'date', 'time'],
rotator_goto: ['az', 'el', 'path'],
lookup_done: ['call', 'name', 'grid', 'country', 'qth', 'state', 'dxcc'],
band_change: ['band', 'band_m', 'freq_hz', 'freq_mhz', 'mode'],
};
const TRIGGERS = [
{ id: 'band_change', label: 'udpp.trgBand' },
{ id: 'qso_logged', label: 'udpp.trgQso' },
{ id: 'rotator_goto', label: 'udpp.trgRotator' },
{ id: 'lookup_done', label: 'udpp.trgLookup' },
];
type Props = { onError: (msg: string) => void };
export function UDPIntegrationsPanel({ onError }: Props) {
@@ -136,6 +173,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
destination_ip: preset.defaults.destination_ip ?? '',
enabled: true,
sort_order: items.filter((i) => i.direction === direction).length,
trigger: '', template: '', transport: 'udp', url: '', line_end: '',
});
}
@@ -388,7 +426,7 @@ function EditDialog({
</div>
)}
{draft.direction === 'outbound' && (
{draft.direction === 'outbound' && draft.service_type !== 'custom' && (
<div className="space-y-1">
<Label>{t('udpp.destinationIp')}</Label>
<Input
@@ -400,6 +438,10 @@ function EditDialog({
</div>
)}
{draft.service_type === 'custom' && (
<CustomFields draft={draft} setDraft={setDraft} />
)}
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={draft.enabled}
@@ -425,3 +467,97 @@ function EditDialog({
// silence unused-import for cn — kept for future styling tweaks
void cn;
// CustomFields is the editor for a custom outbound row: what fires it, how it
// leaves, and what it says.
function CustomFields({ draft, setDraft }: {
draft: UDPConfig;
setDraft: React.Dispatch<React.SetStateAction<UDPConfig>>;
}) {
const { t } = useI18n();
const fields = TRIGGER_FIELDS[draft.trigger ?? ''] ?? [];
const isURL = draft.transport === 'url';
return (
<div className="space-y-4 rounded-md border border-border/60 p-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('udpp.trigger')}</Label>
<Select value={draft.trigger || 'band_change'} onValueChange={(v) => setDraft((d) => ({ ...d, trigger: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{TRIGGERS.map((x) => <SelectItem key={x.id} value={x.id}>{t(x.label)}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>{t('udpp.transport')}</Label>
<Select value={draft.transport || 'udp'} onValueChange={(v) => setDraft((d) => ({ ...d, transport: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="udp">{t('udpp.transportUdp')}</SelectItem>
<SelectItem value="url">{t('udpp.transportUrl')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{isURL ? (
<div className="space-y-1">
<Label>{t('udpp.url')}</Label>
<Input
className="font-mono text-xs"
placeholder="http://192.168.1.50/antenna?band={band}"
value={draft.url ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, url: e.target.value }))}
/>
<div className="text-[10px] text-muted-foreground">{t('udpp.urlHint')}</div>
</div>
) : (
<>
<div className="grid grid-cols-[1fr_auto] gap-2">
<div className="space-y-1">
<Label>{t('udpp.destinationIp')}</Label>
<Input
className="font-mono"
placeholder="127.0.0.1"
value={draft.destination_ip}
onChange={(e) => setDraft((d) => ({ ...d, destination_ip: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label>{t('udpp.lineEnd')}</Label>
<Select value={draft.line_end || 'none'} onValueChange={(v) => setDraft((d) => ({ ...d, line_end: v === 'none' ? '' : v }))}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('udpp.lineEndNone')}</SelectItem>
<SelectItem value="lf">LF</SelectItem>
<SelectItem value="crlf">CRLF</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label>{t('udpp.template')}</Label>
<textarea
className="w-full min-h-[64px] rounded-md border border-input bg-background px-2 py-1.5 font-mono text-xs"
placeholder="<AZIMUT>{az}</AZIMUT>"
value={draft.template ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, template: e.target.value }))}
/>
</div>
</>
)}
{/* What this trigger can actually fill in. */}
<div className="space-y-1">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('udpp.fieldsAvailable')}</div>
<div className="flex flex-wrap gap-1">
{fields.map((f) => (
<code key={f} className="rounded bg-muted px-1 py-px text-[10px] text-muted-foreground">{`{${f}}`}</code>
))}
</div>
<div className="text-[10px] text-muted-foreground">{t('udpp.fieldsHint')}</div>
</div>
</div>
);
}