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"
+4 -4
View File
@@ -156,8 +156,8 @@ const en: Dict = {
'uscty.backfillIntro': 'Resolve county (and grid) for US QSOs already in your log that are missing them. Existing values are kept — only blanks are filled.',
'uscty.backfillRun': 'Fill missing counties',
'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.',
'station.typeHttpGen': 'HTTP relay (home-made / generic)', 'station.onPattern': 'ON URL pattern', 'station.offPattern': 'OFF URL pattern', 'station.patternHint': 'Optional, http or https. {relay} is replaced by the relay number — {relay-1} if the board counts from zero. Put {value} where the boards differ only by a number, and type that number per relay below. Leave both blank if every relay has its own full URL.', 'station.perRelayUrls': 'Per-relay URLs (optional)', 'station.perRelayHint': 'Filled in here, these win over the patterns — for a switch whose channels have nothing in common. State is remembered, not read back: after a restart every relay is re-commanded once.', 'station.onUrlPh': 'ON URL', 'station.offUrlPh': 'OFF URL',
'station.perRelayValues': 'Per-relay values (for {value})', 'station.perValueHint': 'The pattern above contains {value}: these are the values dropped into it, not URLs — e.g. 1, 2, 4, 8 to switch on and 0 to switch off. State is remembered, not read back: after a restart every relay is re-commanded once.', 'station.onValuePh': 'ON value', 'station.offValuePh': 'OFF value',
'station.typeHttpGen': 'HTTP relay (home-made / generic)', 'station.onPattern': 'ON URL pattern', 'station.offPattern': 'OFF URL pattern', 'station.patternHint': 'Optional, http or https. {relay} is the relay number — {relay-1} if the board counts from zero. {value} is that relay\'s label below, so …/relay?on={value} with relay 1 named Ant1 sends …/relay?on=Ant1. Leave both blank if every relay has its own full URL.', 'station.perRelayUrls': 'Per-relay URLs (optional)', 'station.perRelayHint': 'Filled in here, these win over the patterns — for a switch whose channels have nothing in common. {relay} and {value} work here too. State is remembered, not read back: after a restart every relay is re-commanded once.', 'station.onUrlPh': 'ON URL', 'station.offUrlPh': 'OFF URL',
'station.valueNeedsLabels': 'A URL above uses {value}, which sends the relays label — name every relay you switch that way, or its URL goes out with an empty value.',
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotateTo': 'Rotate to {az}°', 'station.rotatorNoRead': 'No heading read', 'station.bands': 'Bands', 'station.nudgeUp': 'Up {n} kHz', 'station.nudgeDown': 'Down {n} kHz', 'station.trackOn': 'Tracking on', 'station.trackOff': 'Tracking off', 'station.trackStepTip': 'Re-tune only when the rig has moved this far', 'station.trackModeTip': 'When the antenna is allowed to re-tune', 'station.trackAlways': 'Every frequency change', 'station.trackStep': 'Past a step', 'station.trackBand': 'Band change only', 'station.trackAlwaysTip': 'Follow every frequency change. Best resonance, but the motors run constantly — and on a SteppIR every move blocks transmit while the elements travel.', 'station.trackStepTipMode': 'Re-tune only once the rig has moved further than the step. Follows a QSY, ignores tuning around.', 'station.trackBandTip': 'Re-tune only when the band changes. The motors move a few times a day and are left alone within a band.', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag the grip handle on the left of a panel to move it. Pick a column count to lay them out in a grid.', 'station.dragMove': 'Drag to move this panel', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.hostHint': 'LAN IP for local use. To reach the board from OUTSIDE, put a full URL here — e.g. https://relay.yourdomain.com — pointing at a reverse proxy (Nginx Proxy Manager…) that fronts the boards HTTP port.', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.',
'awards.followHint': 'Awards shown in the Awards tab. Leave the right side empty to show them all.', 'awards.available': 'All awards', 'awards.followed': 'Followed', 'awards.search': 'Filter…', 'awards.addAll': 'Add all', 'awards.clear': 'Clear', 'awards.allTracked': 'All awards are followed.', 'awards.noneFollowed': 'Nothing followed yet — the Awards tab shows all.',
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
@@ -591,8 +591,8 @@ const fr: Dict = {
'uscty.backfillIntro': "Résout le comté (et le locator) pour les QSO US déjà dans ton log qui n'en ont pas. Les valeurs existantes sont conservées — seuls les vides sont remplis.",
'uscty.backfillRun': 'Remplir les comtés manquants',
'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.',
'station.typeHttpGen': 'Relais HTTP (fait main / générique)', 'station.onPattern': 'Modèle dURL ON', 'station.offPattern': 'Modèle dURL OFF', 'station.patternHint': 'Optionnel, http ou https. {relay} est remplacé par le numéro du relais — {relay-1} si la carte compte à partir de zéro. Mets {value} là où les URL ne diffèrent que par un nombre, et saisis ce nombre par relais ci-dessous. Laisse les deux vides si chaque relais a sa propre URL complète.', 'station.perRelayUrls': 'URL par relais (optionnel)', 'station.perRelayHint': 'Renseignées ici, elles lemportent sur les modèles — pour un commutateur dont les voies nont rien en commun. L’état est mémorisé, pas relu : après un redémarrage chaque relais est recommandé une fois.', 'station.onUrlPh': 'URL ON', 'station.offUrlPh': 'URL OFF',
'station.perRelayValues': 'Valeurs par relais (pour {value})', 'station.perValueHint': 'Le modèle ci-dessus contient {value} : ce sont les valeurs qui y seront insérées, pas des URL — ex. 1, 2, 4, 8 pour activer et 0 pour couper. L’état est mémorisé, pas relu : après un redémarrage chaque relais est recommandé une fois.', 'station.onValuePh': 'Valeur ON', 'station.offValuePh': 'Valeur OFF',
'station.typeHttpGen': 'Relais HTTP (fait main / générique)', 'station.onPattern': 'Modèle dURL ON', 'station.offPattern': 'Modèle dURL OFF', 'station.patternHint': 'Optionnel, http ou https. {relay} est le numéro du relais — {relay-1} si la carte compte à partir de zéro. {value} est le libellé de ce relais ci-dessous : …/relay?on={value} avec le relais 1 nommé Ant1 envoie …/relay?on=Ant1. Laisse les deux vides si chaque relais a sa propre URL complète.', 'station.perRelayUrls': 'URL par relais (optionnel)', 'station.perRelayHint': 'Renseignées ici, elles lemportent sur les modèles — pour un commutateur dont les voies nont rien en commun. {relay} et {value} fonctionnent aussi ici. L’état est mémorisé, pas relu : après un redémarrage chaque relais est recommandé une fois.', 'station.onUrlPh': 'URL ON', 'station.offUrlPh': 'URL OFF',
'station.valueNeedsLabels': 'Une URL ci-dessus utilise {value}, qui envoie le libellé du relais — nomme chaque relais commuté ainsi, sinon son URL part avec une valeur vide.',
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotateTo': 'Tourner vers {az}°', 'station.rotatorNoRead': 'Azimut non lu', 'station.bands': 'Bandes', 'station.nudgeUp': 'Monter de {n} kHz', 'station.nudgeDown': 'Descendre de {n} kHz', 'station.trackOn': 'Suivi actif', 'station.trackOff': 'Suivi inactif', 'station.trackStepTip': 'Ne réaccorder que si le rig a bougé d au moins ça', 'station.trackModeTip': "Quand l'antenne a le droit de se réaccorder", 'station.trackAlways': 'À chaque changement', 'station.trackStep': 'Au-delà d un pas', 'station.trackBand': 'Changement de bande', 'station.trackAlwaysTip': "Suivre chaque changement de fréquence. Résonance idéale, mais les moteurs tournent en permanence — et sur une SteppIR chaque déplacement bloque l'émission le temps du mouvement.", 'station.trackStepTipMode': "Ne réaccorder qu'une fois le rig sorti du pas. Suit un QSY, ignore la recherche autour.", 'station.trackBandTip': "Ne réaccorder qu'au changement de bande. Les moteurs bougent quelques fois par jour et restent tranquilles dans une bande.", 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse la poignée à gauche d un panneau pour le déplacer. Choisis un nombre de colonnes pour la disposition.', 'station.dragMove': 'Glisser pour déplacer ce panneau', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.hostHint': "IP du LAN en local. Pour joindre la carte depuis L'EXTÉRIEUR, saisis une URL complète ici — ex. https://relais.tondomaine.com — pointant vers un reverse proxy (Nginx Proxy Manager…) qui expose le port HTTP de la carte.", 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).',
'awards.followHint': 'Diplômes affichés dans longlet Awards. Laisse la colonne de droite vide pour tous les afficher.', 'awards.available': 'Tous les diplômes', 'awards.followed': 'Suivis', 'awards.search': 'Filtrer…', 'awards.addAll': 'Tout ajouter', 'awards.clear': 'Vider', 'awards.allTracked': 'Tous les diplômes sont suivis.', 'awards.noneFollowed': 'Aucun pour linstant — longlet Awards les montre tous.',
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',