diff --git a/changelog.json b/changelog.json index 3a10dac..e387cfb 100644 --- a/changelog.json +++ b/changelog.json @@ -16,7 +16,7 @@ "Appearance: the Sahara theme is lighter, parchment rather than deep sand, with a terracotta accent.", "UDP: new outbound “Custom message” — you choose what fires it (band change, QSO logged, rotator command, callsign lookup) and what it says, with fields like {band} or {az}. It leaves as a UDP datagram or an HTTP request, which is how most antenna switches are driven.", "Settings: the UDP section is now “Connections” — it no longer carries only UDP. Every custom message is logged with what was sent, and a message that came out empty says so, with a password in a URL redacted.", - "Station relays: new “HTTP relay” device for home-made and generic switches — an ON and an OFF URL, either one pattern with {relay} or one pair per relay. It then follows the same per-band rules as the named boards." + "Station relays: new “HTTP relay” device for home-made and generic switches — an ON and an OFF URL, either one pattern with {relay} or one pair per relay, with a channel count from 1 to 16. It then follows the same per-band rules as the named boards." ], "fr": [ "Envoi de spot : le commentaire porte désormais les références de diplôme après le mode — celles que vous avez attribuées (POTA, SOTA, IOTA…), pas le DXCC, la zone et le préfixe que chacun déduit de l’indicatif. Un auto-spot porte VOS références d’activation.", @@ -32,7 +32,7 @@ "Apparence : le thème Sahara s’éclaircit, parchemin plutôt que sable profond, avec un accent terre cuite.", "UDP : nouvelle sortie « Message personnalisé » — vous choisissez ce qui la déclenche (changement de bande, QSO enregistré, commande de rotor, recherche d’indicatif) et ce qu’elle dit, avec des champs comme {band} ou {az}. Elle part en datagramme UDP ou en requête HTTP, ce qui est la façon dont se pilotent la plupart des commutateurs d’antennes.", "Réglages : la section UDP devient « Connexions » — elle ne porte plus seulement de l’UDP. Chaque message personnalisé est journalisé avec son contenu, un message rendu vide le signale, et un mot de passe dans une URL est masqué.", - "Relais de station : nouveau périphérique « Relais HTTP » pour les commutateurs faits main ou génériques — une URL ON et une URL OFF, soit un modèle avec {relay}, soit une paire par relais. Il suit ensuite les mêmes règles par bande que les cartes reconnues." + "Relais de station : nouveau périphérique « Relais HTTP » pour les commutateurs faits main ou génériques — une URL ON et une URL OFF, soit un modèle avec {relay}, soit une paire par relais, avec un nombre de voies de 1 à 16. Il suit ensuite les mêmes règles par bande que les cartes reconnues." ] }, { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 627e399..58185d1 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -701,7 +701,13 @@ const MOTOR_BAND_DEFAULT_KHZ: Record = { '40m': 7100, '30m': 10125, '20m': 14150, '17m': 18110, '15m': 21150, '12m': 24930, '10m': 28400, '6m': 50150, }; -const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5); +// Fallback only: the rule editor sizes itself from the device's labels, which +// SaveStationDevices already normalises to the real relay count. This is what it +// falls back to when a device somehow has none, so it only needs to be in the +// right neighbourhood — but it is the third place that counts relays, and the +// first two disagreeing is what pinned the HTTP board at four. +const relayCountUI = (type: string) => + type === 'kmtronic' || type === 'denkovi' ? 8 : type === 'httpgen' ? 4 : 5; // Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config // id) — SPE / ACOM / PGXL alike. Module-scoped (not a nested component) so it diff --git a/frontend/src/components/StationControlPanel.tsx b/frontend/src/components/StationControlPanel.tsx index a0a23f1..8e7204a 100644 --- a/frontend/src/components/StationControlPanel.tsx +++ b/frontend/src/components/StationControlPanel.tsx @@ -44,10 +44,16 @@ const TYPE_LABEL: Record = { webswitch: 'WebSwitch 1216H', kmtro // Relay count for a configured device: fixed by type, except Denkovi (4/8) and // the generic USB-serial board, whose channel count the user picks. +// chanCount is the relay count for a device. It MUST agree with +// deviceRelayCount in app.go: the two decide how many labels and URL rows the +// editor shows and how many relays the driver is built for, and a type known to +// one and not the other silently pins the count to its fixed default — which is +// exactly what happened when the HTTP board was added here and not below. const chanCount = (d: Device): number => (d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : d.type === 'dingtian' ? (d.channels && d.channels >= 1 ? d.channels : 2) - : (RELAY_COUNT[d.type] ?? 5); + : d.type === 'httpgen' ? (d.channels && d.channels >= 1 ? d.channels : 4) + : (RELAY_COUNT[d.type] ?? 5); function blankDevice(): Device { return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') }; diff --git a/relaycount_test.go b/relaycount_test.go new file mode 100644 index 0000000..2d7f4b0 --- /dev/null +++ b/relaycount_test.go @@ -0,0 +1,30 @@ +package main + +import "testing" + +// The relay count is decided in Go and mirrored in the panel. When the HTTP +// board was added to one and not the other, an operator could pick 8 channels +// and still get 4 rows — the selector "did nothing". +// +// This pins the Go side, which is the one the driver is built from. +func TestDeviceRelayCountHonoursTheChosenChannels(t *testing.T) { + for _, typ := range []string{"httpgen", "usbrelay", "denkovi", "dingtian"} { + for _, n := range []int{1, 2, 4, 8, 16} { + if got := deviceRelayCount(StationDevice{Type: typ, Channels: n}); got != n { + t.Errorf("%s with %d channels → %d", typ, n, got) + } + } + } + // Fixed-size boards ignore it: their hardware decides. + for typ, want := range map[string]int{"webswitch": 5, "kmtronic": 8} { + if got := deviceRelayCount(StationDevice{Type: typ, Channels: 3}); got != want { + t.Errorf("%s → %d, want its fixed %d", typ, got, want) + } + } + // Unset falls back to something sensible per type, not to another's default. + for typ, want := range map[string]int{"httpgen": 4, "dingtian": 2, "usbrelay": 8, "denkovi": 8} { + if got := deviceRelayCount(StationDevice{Type: typ}); got != want { + t.Errorf("%s with no choice → %d, want %d", typ, got, want) + } + } +}