fix(relays): {value} is the relay's label, not a per-relay value

It was built the other way round on a misreading of two screenshots: the pattern
held {value} and the per-relay boxes held numbers to drop into it. The ask was
simpler and better — {value} is the name typed in Relay labels, so a switch
addressed by antenna name is one pattern instead of eight URLs:

  http://10.10.10.100/relay?on={value}   relay 1 named Ant1  →  ?on=Ant1

Renaming the antenna re-addresses it, and the name on the button and the name on
the wire cannot drift apart because they are the same string. It works in the
per-relay URLs and in the patterns alike, so the per-relay boxes go back to
holding URLs and nothing about them changes meaning any more.

The label is percent-encoded with %20 rather than "+" for a space: "+" is a
space only in a query string and a literal plus in a path, and this can land in
either half of a URL.

{value} on a relay with no label would send "?on=", an empty parameter that most
boards answer with a cheerful 200 and no movement. The driver refuses it and
names the label as what is missing; the editor warns while it is being typed,
beside the empty box rather than after an antenna fails to switch. The labels
also join the driver's cache key — they are part of the wire format now.
This commit is contained in:
2026-08-16 00:09:18 +02:00
parent c2ff0f714a
commit 480d24384b
6 changed files with 128 additions and 98 deletions
@@ -696,11 +696,13 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
const isDenkovi = device.type === 'denkovi';
const isUsbRelay = device.type === 'usbrelay';
const isHTTPGen = device.type === 'httpgen';
// {value} in a pattern changes what the boxes below hold — a value to drop
// into it instead of a whole URL. The grid says which as soon as it is typed,
// because the two are indistinguishable once entered and getting it wrong
// switches an antenna somewhere unexpected.
const usesValue = `${device.on_pattern ?? ''}${device.off_pattern ?? ''}`.includes('{value}');
// {value} sends a relay's label, so a URL using it on an unnamed relay would
// go out with an empty parameter. Warn while it is being typed rather than at
// the moment an antenna fails to switch.
const valueNeedsLabels = isHTTPGen
&& [...(device.on_urls ?? []), ...(device.off_urls ?? []), device.on_pattern ?? '', device.off_pattern ?? '']
.some((s) => (s ?? '').includes('{value}'))
&& device.labels.some((l) => !l.trim());
// COM ports for the generic USB-serial relay picker.
const [serialPorts, setSerialPorts] = useState<string[]>([]);
useEffect(() => {
@@ -874,12 +876,12 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
</div>
<div className="text-[10px] text-muted-foreground">{t('station.patternHint')}</div>
<div className="space-y-1">
<Label>{usesValue ? t('station.perRelayValues') : t('station.perRelayUrls')}</Label>
<Label>{t('station.perRelayUrls')}</Label>
<div className="space-y-1">
{device.labels.map((_, i) => (
<div key={i} className="grid grid-cols-[2.5rem_1fr_1fr] items-center gap-2">
<span className="text-[11px] text-muted-foreground">{i + 1}</span>
<Input className="h-8 font-mono text-[11px]" placeholder={usesValue ? t('station.onValuePh') : t('station.onUrlPh')}
<Input className="h-8 font-mono text-[11px]" placeholder={t('station.onUrlPh')}
value={device.on_urls?.[i] ?? ''}
onChange={(e) => {
const on_urls = [...(device.on_urls ?? [])];
@@ -887,7 +889,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
on_urls[i] = e.target.value;
onChange({ ...device, on_urls });
}} />
<Input className="h-8 font-mono text-[11px]" placeholder={usesValue ? t('station.offValuePh') : t('station.offUrlPh')}
<Input className="h-8 font-mono text-[11px]" placeholder={t('station.offUrlPh')}
value={device.off_urls?.[i] ?? ''}
onChange={(e) => {
const off_urls = [...(device.off_urls ?? [])];
@@ -898,13 +900,17 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
</div>
))}
</div>
<div className="text-[10px] text-muted-foreground">{usesValue ? t('station.perValueHint') : t('station.perRelayHint')}</div>
<div className="text-[10px] text-muted-foreground">{t('station.perRelayHint')}</div>
</div>
</div>
)}
<div className="space-y-1">
<Label>{t('station.labels')}</Label>
{/* {value} sends the label, so an unnamed relay would go out as "?on=".
Said here, beside the empty box, rather than when the antenna fails
to switch and the log is the only place that explains why. */}
{valueNeedsLabels && <p className="text-[10px] text-warning">{t('station.valueNeedsLabels')}</p>}
<div className="grid grid-cols-4 gap-2">
{device.labels.map((lab, i) => (
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"