feat(station): the radio and both keyers on the Station Control tab

This tab began as the relay and rotator dashboard and stopped there: the
three things an operator touches most — the radio, the CW keyer, the
voice keyer — were the ones missing from the page that claims to show the
station.

The radio card carries the frequency and the mode large, because that is
what gets glanced at, and the split pair only when there IS a split: a
second frequency shown at all times is one more number to read past. When
CAT is down it says which kind of down — switched off, or on and not
answering.

The CW keyer card carries the speed, which is the control an operator
reaches for mid-QSO when a station comes back faster than expected, and
Stop beside it because a message going to the wrong callsign has to end
now. The voice keyer card carries the recorded messages themselves: a
card that only said "idle" would be a light, not a control.

Each polls its own binding and holds its own state, like the supply card
above them, so they drop into the grid and reorder with everything else.
The two keyers appear only when there is something behind them — a port
configured, a message actually recorded — because an operator who works
neither should not be handed two dead cards. That question is asked once
on opening the tab: a keyer is bought and wired, not something that
appears mid-session.
This commit is contained in:
2026-09-07 23:48:10 +02:00
parent bc880ef6bd
commit b655cd9631
3 changed files with 223 additions and 1 deletions
+10
View File
@@ -1,4 +1,14 @@
[
{
"version": "0.27.18",
"date": "",
"en": [
"Station Control shows what commands the station, not only what it switches. The radio is there now — frequency, mode, band, and the split pair when there is one — with the CW keyer beside it (speed up and down, and Stop, because a message going to the wrong callsign has to end now) and the voice keyer with its recorded messages as buttons, so a CQ goes out without leaving the tab. The two keyers appear only when there is something behind them: a port configured, or a message actually recorded. All three move and reorder with the other cards."
],
"fr": [
"Contrôle station montre ce qui commande la station, et plus seulement ce qui la commute. La radio y figure désormais — fréquence, mode, bande, et le couple split quand il y en a un — avec à côté le manipulateur CW (vitesse en plus ou en moins, et Stop, parce qu'un message parti vers le mauvais indicatif doit s'arrêter tout de suite) et le manipulateur vocal avec ses messages enregistrés en boutons, pour lancer un CQ sans quitter l'onglet. Les deux manipulateurs n'apparaissent que s'il y a quelque chose derrière : un port configuré, ou un message réellement enregistré. Les trois se déplacent et se réordonnent avec les autres cartes."
]
},
{
"version": "0.27.17",
"date": "",
+205 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown } from 'lucide-react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown, Radio, Zap, Mic } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -25,6 +25,9 @@ import {
GetAmpStatuses, GetFlexState,
GetTunerGeniusStatus, GetTunerGeniusSettings,
GetPSUStatus, GetPSUSettings, SetPSUOutput,
GetCATState,
GetWinkeyerStatus, WinkeyerSetSpeed, WinkeyerStop, WinkeyerConnect,
GetDVKStatus, GetDVKMessages, DVKPlay, DVKStop,
} from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
@@ -82,6 +85,181 @@ function PSUCard({ st, busy, onToggle, t }: {
);
}
// ── What commands the station, and not only what it switches ───────────────
//
// This tab began as the relay and rotator dashboard, and stopped there: the
// three things an operator touches most — the radio, the CW keyer and the voice
// keyer — were the ones missing from the page that claims to show the station.
//
// Each card polls its own binding and holds its own state, like PSUCard above.
// That is deliberate: they can then be dropped into the grid, reordered and
// hidden with everything else, and adding one costs nothing to the panel around
// it. None of them tries to be the full console — a card says what the thing is
// doing and offers the one or two controls worth reaching for from here.
const fmtMHz = (hz: number) => (hz > 0 ? (hz / 1e6).toFixed(6) : '—');
// The radio. The frequency and the mode large, because that is what an operator
// glances at, and the split pair underneath only when there IS a split — a
// second frequency shown at all times is one more number to read past.
function RigCard({ t }: { t: (k: string, v?: any) => string }) {
const [st, setSt] = useState<any>(null);
useEffect(() => {
let alive = true;
const tick = () => GetCATState().then((s: any) => { if (alive) setSt(s); }).catch(() => {});
tick();
const h = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(h); };
}, []);
const on = !!st?.connected;
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Radio className="size-4 text-primary" />
<div className="text-sm font-semibold truncate">{st?.rig || t('station.rig')}</div>
<span className={cn('ml-auto size-2 rounded-full shrink-0', on ? 'bg-success' : 'bg-muted-foreground/40')}
title={on ? t('station.online') : (st?.error || t('station.offline'))} />
</div>
<div className="p-3 space-y-2">
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold tabular-nums leading-none">{fmtMHz(st?.freq_hz ?? 0)}</span>
<span className="text-xs text-muted-foreground">MHz</span>
</div>
<div className="flex items-center gap-2 flex-wrap text-[11px]">
{!!st?.mode && <span className="rounded px-1.5 py-px font-semibold bg-primary/15 text-primary border border-primary/30">{st.mode}</span>}
{!!st?.band && <span className="text-muted-foreground">{st.band}</span>}
{!!st?.vfo && <span className="text-muted-foreground">VFO {st.vfo}</span>}
{!!st?.backend && <span className="ml-auto text-muted-foreground/70 truncate">{st.backend}</span>}
</div>
{st?.split && (
<div className="flex items-center gap-2 text-[11px] tabular-nums">
<span className="rounded px-1.5 py-px font-semibold bg-warning-muted text-warning-muted-foreground border border-warning-border">SPLIT</span>
<span className="text-muted-foreground">RX {fmtMHz(st?.freq_rx_hz ?? 0)}</span>
</div>
)}
{!on && (
<div className="text-[11px] text-muted-foreground truncate" title={st?.error || ''}>
{st?.enabled ? (st?.error || t('station.rigDown')) : t('station.rigOff')}
</div>
)}
</div>
</div>
);
}
// The CW keyer. Speed is the control an operator reaches for mid-QSO — a
// station answers faster or slower than expected and the reply has to match —
// so it is here rather than only in the docked panel, and Stop is beside it
// because a message sent to the wrong callsign has to end NOW.
function KeyerCard({ t }: { t: (k: string, v?: any) => string }) {
const [st, setSt] = useState<any>(null);
useEffect(() => {
let alive = true;
const tick = () => GetWinkeyerStatus().then((s: any) => { if (alive) setSt(s); }).catch(() => {});
tick();
const h = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(h); };
}, []);
const on = !!st?.connected;
const wpm = st?.wpm || 0;
const step = (d: number) => {
const w = Math.max(5, Math.min(50, wpm + d));
setSt((cur: any) => ({ ...(cur ?? {}), wpm: w })); // shows at once; the poll confirms
WinkeyerSetSpeed(w).catch(() => {});
};
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Zap className="size-4 text-primary" />
<div className="text-sm font-semibold truncate">{t('station.keyer')}</div>
{st?.busy && <span className="text-[10px] font-bold text-danger animate-pulse">TX</span>}
<span className={cn('ml-auto size-2 rounded-full shrink-0', on ? 'bg-success' : 'bg-muted-foreground/40')}
title={on ? t('station.online') : (st?.error || t('station.offline'))} />
</div>
<div className="p-3 space-y-2">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" className="size-7" disabled={!on} onClick={() => step(-1)}>
<Minus className="size-3.5" />
</Button>
<div className="flex items-baseline gap-1">
<span className="text-xl font-semibold tabular-nums leading-none">{wpm || '—'}</span>
<span className="text-xs text-muted-foreground">WPM</span>
</div>
<Button variant="outline" size="icon" className="size-7" disabled={!on} onClick={() => step(1)}>
<Plus className="size-3.5" />
</Button>
<Button variant="outline" size="sm" className="ml-auto h-7 px-2" disabled={!on || !st?.busy}
onClick={() => WinkeyerStop().catch(() => {})}>
<Square className="size-3 mr-1" />{t('station.stop')}
</Button>
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="truncate">{st?.port || t('station.noPort')}</span>
{!!st?.version && <span className="ml-auto shrink-0">v{st.version}</span>}
</div>
{!on && (
<Button variant="outline" size="sm" className="w-full h-7"
onClick={() => WinkeyerConnect().catch(() => {})}>
{t('station.connect')}
</Button>
)}
</div>
</div>
);
}
// The voice keyer. The messages themselves, because a card that only said
// "idle" would be a light and not a control — from here a CQ goes out without
// leaving the tab.
function VoiceKeyerCard({ t }: { t: (k: string, v?: any) => string }) {
const [st, setSt] = useState<any>({ playing: false, recording: false });
const [msgs, setMsgs] = useState<any[]>([]);
useEffect(() => {
let alive = true;
const tick = () => GetDVKStatus().then((s: any) => { if (alive) setSt(s ?? {}); }).catch(() => {});
tick();
const h = window.setInterval(tick, 1000);
// The recordings change when the operator records one, which is rare and
// never from this tab — read once, and again only on a status change worth
// it would be more machinery than it saves.
GetDVKMessages().then((m: any[]) => { if (alive) setMsgs(m ?? []); }).catch(() => {});
return () => { alive = false; window.clearInterval(h); };
}, []);
const recorded = msgs.filter((m) => m.has_audio);
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Mic className="size-4 text-primary" />
<div className="text-sm font-semibold truncate">{t('station.voiceKeyer')}</div>
{st?.playing && <span className="text-[10px] font-bold text-danger animate-pulse">TX</span>}
{st?.recording && <span className="text-[10px] font-bold text-warning animate-pulse">REC</span>}
<Button variant="ghost" size="sm" className="ml-auto h-6 px-2 text-[11px]"
disabled={!st?.playing} onClick={() => DVKStop().catch(() => {})}>
<Square className="size-3 mr-1" />{t('station.stop')}
</Button>
</div>
<div className="p-3">
{recorded.length === 0 ? (
<div className="text-[11px] text-muted-foreground">{t('station.noVoiceMsg')}</div>
) : (
<div className="flex flex-wrap gap-1.5">
{recorded.map((m) => (
<button key={m.slot} type="button"
onClick={() => DVKPlay(m.slot).catch(() => {})}
disabled={st?.playing}
title={`${m.duration_sec?.toFixed?.(1) ?? ''}s`}
className="rounded-md border border-border bg-muted/30 px-2 py-1 text-[11px] font-medium hover:bg-muted disabled:opacity-40">
<span className="text-muted-foreground mr-1">F{m.slot}</span>
{m.label || `#${m.slot}`}
</button>
))}
</div>
)}
</div>
</div>
);
}
type Device = {
id: string; type: string; name: string; host: string;
user?: string; pass?: string; channels?: number; labels: string[];
@@ -317,6 +495,25 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
}, [poll, pollAnt, devices.length]);
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
// Whether the two keyers exist at this station. Asked ONCE, on opening the
// tab: a keyer is bought, wired and configured, not something that appears
// mid-session, and polling for the answer would be a round trip a second for
// a fact that does not change. A keyer counts as present when it is connected
// or a port is configured for it, the voice keyer when at least one message
// has actually been recorded — an empty set of slots is not a keyer.
const [keyerShown, setKeyerShown] = useState(false);
const [dvkShown, setDvkShown] = useState(false);
useEffect(() => {
let alive = true;
GetWinkeyerStatus().then((s: any) => {
if (alive) setKeyerShown(!!s && (!!s.connected || !!String(s.port ?? '').trim()));
}).catch(() => {});
GetDVKMessages().then((m: any[]) => {
if (alive) setDvkShown((m ?? []).some((x) => x?.has_audio));
}).catch(() => {});
return () => { alive = false; };
}, []);
// Reorder so `dragged` lands just before `target`.
const onDrop = (targetId: string) => {
const src = dragId.current; dragId.current = null;
@@ -419,6 +616,13 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
// single ~430px column — they are the same cards the FlexRadio panel shows
// full-width, and they need that room here too.
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
// The radio first: it is the station, and everything else on this page is
// something attached to it. Then the two keyers, each only when there is
// something behind it — an operator who works neither CW nor voice keyer
// should not be given two dead cards to read past.
widgets.push({ id: 'rig', node: <RigCard t={t} /> });
if (keyerShown) widgets.push({ id: 'keyer', node: <KeyerCard t={t} /> });
if (dvkShown) widgets.push({ id: 'dvk', node: <VoiceKeyerCard t={t} />, wide: true });
if (rot.enabled) {
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pokeRotorHeading} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
}
+8
View File
@@ -266,6 +266,10 @@ const en: Dict = {
'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.insecureTls': 'Accept a self-signed certificate', 'station.insecureTlsHint': 'A relay board on your own network signs its own certificate, which nothing can verify. Leave this off for a board reached over the internet through a proxy: there the certificate is real, and checking it is what protects the link.', '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.rig': 'Radio', 'station.keyer': 'CW keyer', 'station.voiceKeyer': 'Voice keyer',
'station.rigDown': 'CAT is on but the radio is not answering',
'station.rigOff': 'CAT is switched off', 'station.noPort': 'no port configured',
'station.connect': 'Connect', 'station.noVoiceMsg': 'No message recorded — Settings ▸ Audio.',
'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.motorWidgetShow': 'Motorised antenna · click to show', 'station.motorWidgetHide': 'Motorised antenna — shown · click to hide', 'station.hideWidget': 'Hide this widget', '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',
@@ -880,6 +884,10 @@ const fr: Dict = {
'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.insecureTls': 'Accepter un certificat auto-signé', 'station.insecureTlsHint': 'Une carte relais sur ton propre réseau signe elle-même son certificat, que rien ne peut vérifier. Laisse décoché pour une carte atteinte par internet à travers un proxy : là le certificat est réel, et le vérifier est ce qui protège la liaison.', '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.rig': 'Radio', 'station.keyer': 'Manipulateur CW', 'station.voiceKeyer': 'Manipulateur vocal',
'station.rigDown': 'le CAT est actif mais la radio ne répond pas',
'station.rigOff': 'le CAT est désactivé', 'station.noPort': 'aucun port configuré',
'station.connect': 'Connecter', 'station.noVoiceMsg': 'Aucun message enregistré — Réglages ▸ Audio.',
'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.motorWidgetShow': 'Antenne motorisée · cliquer pour afficher', 'station.motorWidgetHide': 'Antenne motorisée — affichée · cliquer pour masquer', 'station.hideWidget': 'Masquer ce widget', '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',