diff --git a/changelog.json b/changelog.json index c24c3c2..9951a16 100644 --- a/changelog.json +++ b/changelog.json @@ -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": "", diff --git a/frontend/src/components/StationControlPanel.tsx b/frontend/src/components/StationControlPanel.tsx index ec533dc..3902f64 100644 --- a/frontend/src/components/StationControlPanel.tsx +++ b/frontend/src/components/StationControlPanel.tsx @@ -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(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 ( +
+
+ +
{st?.rig || t('station.rig')}
+ +
+
+
+ {fmtMHz(st?.freq_hz ?? 0)} + MHz +
+
+ {!!st?.mode && {st.mode}} + {!!st?.band && {st.band}} + {!!st?.vfo && VFO {st.vfo}} + {!!st?.backend && {st.backend}} +
+ {st?.split && ( +
+ SPLIT + RX {fmtMHz(st?.freq_rx_hz ?? 0)} +
+ )} + {!on && ( +
+ {st?.enabled ? (st?.error || t('station.rigDown')) : t('station.rigOff')} +
+ )} +
+
+ ); +} + +// 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(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 ( +
+
+ +
{t('station.keyer')}
+ {st?.busy && TX} + +
+
+
+ +
+ {wpm || '—'} + WPM +
+ + +
+
+ {st?.port || t('station.noPort')} + {!!st?.version && v{st.version}} +
+ {!on && ( + + )} +
+
+ ); +} + +// 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({ playing: false, recording: false }); + const [msgs, setMsgs] = useState([]); + 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 ( +
+
+ +
{t('station.voiceKeyer')}
+ {st?.playing && TX} + {st?.recording && REC} + +
+
+ {recorded.length === 0 ? ( +
{t('station.noVoiceMsg')}
+ ) : ( +
+ {recorded.map((m) => ( + + ))} +
+ )} +
+
+ ); +} + 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: }); + if (keyerShown) widgets.push({ id: 'keyer', node: }); + if (dvkShown) widgets.push({ id: 'dvk', node: , wide: true }); if (rot.enabled) { widgets.push({ id: 'rotator', node: }); } diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index c4828f0..936d10e 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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 relay’s 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 board’s 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 d’URL ON', 'station.offPattern': 'Modèle d’URL 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 l’emportent sur les modèles — pour un commutateur dont les voies n’ont 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 l’onglet 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 l’instant — l’onglet Awards les montre tous.', 'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',