Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0f25aea9b | ||
|
|
f91b83ee12 | ||
|
|
a554257d5f | ||
|
|
a20d9a0b76 | ||
|
|
92ee8aadde | ||
|
|
61c6666a4c | ||
|
|
9f5c482667 | ||
|
|
b9ea077a64 | ||
|
|
a48fa0b2e7 | ||
|
|
821883dfd3 | ||
|
|
ef087492cc | ||
|
|
98f11ee3d0 | ||
|
|
058f164ab6 | ||
|
|
6c47c27775 | ||
|
|
7410fa4825 | ||
|
|
246d9d1b6a | ||
|
|
d12cfe94cd | ||
|
|
ede81d3926 | ||
|
|
f27faa81a6 | ||
|
|
69e6c20b97 | ||
|
|
d93c40ebb3 | ||
|
|
dfe3afbf2d | ||
|
|
bb3542c920 | ||
|
|
a966abd9df | ||
|
|
95482ec1d8 | ||
|
|
500fe8bfec | ||
|
|
87ff0a9e16 | ||
|
|
18f9b915c5 | ||
|
|
b3fdd0b7fa |
+60
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deniedCallHashes lists station callsigns not permitted to run this build,
|
||||||
|
// stored as SHA-256 hex of the base call so the calls themselves appear nowhere
|
||||||
|
// in the source or the compiled binary. Enforcement is best-effort by design —
|
||||||
|
// the call is entered by the operator and can be changed — so this only turns
|
||||||
|
// away straightforward use, not a determined one.
|
||||||
|
var deniedCallHashes = map[string]struct{}{
|
||||||
|
"2282a88b3e5e4eebc6b8174d005bb46d32025758b7741bdcf34f2b3621c02205": {},
|
||||||
|
"0ee09fe60817a2a4982f5e5b14a60b8dbb11cff55b3d36661213c4cbdd0933ea": {},
|
||||||
|
"46fb61e71afb40627cb6493a6a59c9d4d0231ee3cad1a2bf3fcc4cdfce36fabc": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// callDenied reports whether a callsign is on deniedCallHashes. The call is
|
||||||
|
// reduced to its base form (upper-cased, portable prefix/suffix dropped) the
|
||||||
|
// same way extsvc does, so F4XYZ/P matches F4XYZ.
|
||||||
|
func callDenied(call string) bool {
|
||||||
|
base := denyBaseCall(call)
|
||||||
|
if base == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(base))
|
||||||
|
_, bad := deniedCallHashes[hex.EncodeToString(sum[:])]
|
||||||
|
return bad
|
||||||
|
}
|
||||||
|
|
||||||
|
// denyBaseCall mirrors extsvc.baseCall: for a slashed form it returns the
|
||||||
|
// longest token (the real call), otherwise the call itself, upper-cased.
|
||||||
|
func denyBaseCall(s string) string {
|
||||||
|
s = strings.ToUpper(strings.TrimSpace(s))
|
||||||
|
if !strings.Contains(s, "/") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
best := ""
|
||||||
|
for _, part := range strings.Split(s, "/") {
|
||||||
|
if len(part) > len(best) {
|
||||||
|
best = part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforceCallGate exits the process silently when any of the supplied callsigns
|
||||||
|
// is denied. Called once at startup, after the profiles are loaded: the first
|
||||||
|
// launch is where the operator enters and saves the call, so a denied build
|
||||||
|
// simply does not come back up on the next launch. No window, no message.
|
||||||
|
func enforceCallGate(calls ...string) {
|
||||||
|
for _, c := range calls {
|
||||||
|
if callDenied(c) {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,88 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.23.6",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Log viewer: the window now keeps twice as much history (512 KB instead of 256 KB, ~3200 lines). During a busy trace the oldest lines used to scroll out of the buffer while you were still reading them; the larger window holds them.",
|
||||||
|
"Ultrabeam over a remote link: fixed intermittent connection drops, phantom frequency jumps and wrong element-length readings. On a slow link a command that timed out left its late reply in the stream, and the next command read it as its own — crossing a status query with an element-length one, for instance. The stream is now flushed of any stale reply before each command, keeping the exchange in step.",
|
||||||
|
"Ultrabeam: the 'moving' indicator and the Flex TX-inhibit now react the instant you click a band or pattern, instead of up to a status poll (~2 s) later. A commanded move reports motion immediately; the real motor state takes over once the next poll reads it.",
|
||||||
|
"CAT sharing (FT8): JTDX/WSJT-X transmit correctly again through OpsLog's shared rig. get_ptt now echoes the PTT state the client last commanded — clients poll it DURING transmit to confirm the rig is keyed, and OpsLog always answering 'receiving' made them give up and drop the over after a second or two. The 0.23.5 'Fake It' anti-drift tweak was also reverted (it had made this total — no transmit at all); the small dial creep in 'Fake It' split can return, and JTDX's 'Split Operation: None' avoids it.",
|
||||||
|
"CAT settings: trimmed the long help text under the Kenwood and CAT-sharing fields down to the essentials — less clutter.",
|
||||||
|
"Entry form: fixed two ways a stray QSO could sneak in. Pressing Enter in the TX/RX frequency field now only tunes the radio — it was also submitting the contact. And a bare number with no letter (JTDX/WSJT-X sometimes broadcasts \"1\" as the DX call) is no longer accepted into the callsign field, where it could get logged as callsign \"1\".",
|
||||||
|
"SPE amplifier: switching the amp off in OpsLog now shows \"off\" straight away and lets you switch it back on without restarting. The already-open connection kept reading as connected after the off keystroke, so the panel stayed \"on\" and the ON button did nothing until a restart. OpsLog now drops the link on power-off, exactly as a fresh start would — the ON button's RTS/DTR wake then works.",
|
||||||
|
"Alt+W clears the QSO entry, the shortcut used by most other loggers. Unlike Esc it always works, even when the CW keyer has Esc reserved.",
|
||||||
|
"The Grid box no longer pops outside the QSO entry panel when the window is narrowed — it wraps to its own line instead.",
|
||||||
|
"Selecting a QSO in the Recent QSOs list now shows that station in the Stats (F1) matrix, instead of leaving it blank.",
|
||||||
|
"Stats (F1): click any coloured band/mode square to list the contacts behind it.",
|
||||||
|
"Awards: the DXCC list now shows each entity’s main prefix (XE, DL, F…) in its own sortable, searchable column.",
|
||||||
|
"DX Cluster: new option \"Already worked only on the same slot\" (Settings → DX Cluster). With it on, a spot reads \"worked\" only when you worked that callsign on the SAME band and mode — not just anywhere. It respects the digital-mode grouping (Settings → General): grouped, a 20m FT8 contact also marks a 20m FT4 spot as worked; ungrouped, FT8 and FT4 are separate slots."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Visionneuse de log : la fenêtre conserve deux fois plus d'historique (512 Ko au lieu de 256 Ko, ~3200 lignes). Lors d'une trace chargée, les plus vieilles lignes défilaient hors du buffer pendant qu'on les lisait encore ; la fenêtre agrandie les garde.",
|
||||||
|
"Ultrabeam en remote : coupures de connexion intermittentes, sauts de fréquence fantômes et longueurs d'éléments erronées corrigés. Sur un lien lent, une commande qui expirait laissait sa réponse tardive dans le flux, et la commande suivante la lisait comme la sienne — croisant par exemple une requête de statut avec une requête de longueurs d'éléments. Le flux est désormais vidé de toute réponse périmée avant chaque commande, gardant l'échange synchronisé.",
|
||||||
|
"Ultrabeam : l'indicateur « en mouvement » et l'inhibition d'émission Flex réagissent désormais dès que vous cliquez sur une bande ou un diagramme, au lieu d'attendre jusqu'à un poll de statut (~2 s). Un mouvement commandé signale le déplacement immédiatement ; l'état réel des moteurs prend le relais dès le poll suivant.",
|
||||||
|
"Partage CAT (FT8) : JTDX/WSJT-X émettent de nouveau correctement via la radio partagée d'OpsLog. get_ptt renvoie désormais l'état PTT commandé en dernier par le client — les clients l'interrogent PENDANT l'émission pour confirmer que la radio est en émission, et comme OpsLog répondait toujours « réception », ils abandonnaient et coupaient l'émission au bout d'une seconde ou deux. L'ajustement anti-drift « Fake It » de la 0.23.5 a aussi été annulé (il rendait le problème total — aucune émission) ; le léger glissement du VFO en split « Fake It » peut réapparaître, et « Split Operation : None » dans JTDX l'évite.",
|
||||||
|
"Réglages CAT : textes d'aide raccourcis sous les champs Kenwood et partage CAT — moins de fouillis.",
|
||||||
|
"Formulaire de saisie : deux façons de logguer un QSO parasite corrigées. Appuyer sur Entrée dans le champ fréquence TX/RX ne fait plus que syntoniser la radio — cela validait aussi le contact. Et un nombre nu sans lettre (JTDX/WSJT-X diffuse parfois « 1 » comme DX call) n'est plus accepté dans le champ indicatif, où il pouvait être loggué comme indicatif « 1 ».",
|
||||||
|
"Ampli SPE : éteindre l'ampli depuis OpsLog affiche désormais « off » tout de suite et permet de le rallumer sans redémarrer. La connexion déjà ouverte continuait d'être vue comme connectée après la touche d'extinction, le panneau restait donc « on » et le bouton ON ne faisait rien jusqu'à un redémarrage. OpsLog largue maintenant le lien à l'extinction, exactement comme un démarrage à neuf — le réveil RTS/DTR du bouton ON fonctionne alors.",
|
||||||
|
"Alt+W efface la saisie du QSO, le raccourci utilisé par la plupart des autres logiciels. Contrairement à Échap, il fonctionne toujours, même quand Échap est réservé au manipulateur CW.",
|
||||||
|
"Le champ Locator ne déborde plus du panneau de saisie quand la fenêtre est rétrécie — il passe à la ligne.",
|
||||||
|
"Sélectionner un QSO dans la liste des QSO récents affiche désormais cette station dans la matrice Stats (F1), au lieu de la laisser vide.",
|
||||||
|
"Stats (F1) : cliquer sur une case bande/mode colorée liste les contacts correspondants.",
|
||||||
|
"Diplômes : la liste DXCC affiche le préfixe principal de chaque entité (XE, DL, F…) dans une colonne triable et cherchable.",
|
||||||
|
"DX Cluster : nouvelle option « Déjà contacté seulement sur le même slot » (Réglages → DX Cluster). Activée, un spot n'affiche « contacté » que si vous avez contacté cet indicatif sur la MÊME bande et le MÊME mode — pas juste n'importe où. Elle respecte le groupage des modes numériques (Réglages → Général) : groupé, un contact 20m FT8 marque aussi un spot 20m FT4 comme contacté ; dégroupé, FT8 et FT4 sont des slots distincts."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.23.5",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"CAT sharing (Hamlib rigctld): fixed a slow frequency drift in FT8. With a digital app (JTDX/WSJT-X) sharing OpsLog's rig in \"Fake It\" split, each transmit crept the dial down and never came back. The app follows the dial by polling, and our reply lagged the last tune by a poll cycle, so right after a transmission it read the still-shifted frequency, took it for a manual QSY and adopted it. Reads now echo the last commanded frequency until the rig confirms it — the dial holds. Applies to every backend, not just Kenwood."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Partage CAT (rigctld Hamlib) : dérive lente de fréquence en FT8 corrigée. Avec une app numérique (JTDX/WSJT-X) partageant la radio d'OpsLog en split « Fake It », chaque passage en émission faisait descendre le VFO sans jamais revenir. L'app suit le VFO en l'interrogeant, et notre réponse était en retard d'un cycle sur la dernière syntonisation : juste après une émission elle lisait la fréquence encore décalée, la prenait pour un QSY manuel et l'adoptait. Les lectures renvoient désormais la dernière fréquence commandée jusqu'à confirmation de la radio — le VFO tient. Vaut pour tous les backends, pas seulement Kenwood."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.23.4",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"CW keyer (Kenwood/Elecraft): choosing the Kenwood/Elecraft engine now actually switches to it. A bug left the keyer on WinKeyer even though the setting showed Kenwood/Elecraft, so its CW-over-CAT never ran.",
|
||||||
|
"Kenwood/Elecraft data mode: a new setting (Settings → CAT) chooses what a data mode (FT8/PSK…) sets on the rig — USB (default), DATA mode (MD6, for an Elecraft K3/K4), or leave the rig's mode unchanged (safest for a TS-590SG/TS-990S, whose data mode is a USB modifier set on the radio). There is no single command that fits every rig, so it's now the operator's choice.",
|
||||||
|
"Ultrabeam over a remote link: changing the pattern (Normal / 180° / bidirectional) no longer snaps back to the old one after a few seconds. The commanded pattern is now held while the motors are still moving plus a grace window after they stop, so the slow remote status poll — which lagged behind the antenna — can't revert the display while the change is in flight.",
|
||||||
|
"QSL Manager: closing the window — or starting another download — now cancels the one still in progress, so a slow QRZ sync no longer keeps running in the background and bleeds its log into a freshly started LoTW run. The QRZ download summary also spells out that only confirmed QSOs are acted on (e.g. \"7950 of 26164 are confirmed\") — a full QRZ logbook has many not-yet-confirmed QSOs, so nothing is lost even though the old wording read as if only a fraction came back. (A LoTW \"http 503\" is separate: the ARRL server being down — retry later.)"
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Keyer CW (Kenwood/Elecraft) : choisir le moteur Kenwood/Elecraft y bascule désormais réellement. Un bug laissait le keyer sur WinKeyer alors que le réglage affichait Kenwood/Elecraft, son CW-sur-CAT ne démarrait donc jamais.",
|
||||||
|
"Mode data Kenwood/Elecraft : un nouveau réglage (Réglages → CAT) choisit ce qu'un mode data (FT8/PSK…) règle sur la radio — USB (défaut), mode DATA (MD6, pour un Elecraft K3/K4), ou ne pas changer le mode de la radio (le plus sûr pour un TS-590SG/TS-990S dont le mode data est un modificateur d'USB réglé sur la radio). Aucune commande unique ne convient à toutes les radios, c'est donc désormais au choix de l'opérateur.",
|
||||||
|
"Ultrabeam en remote : changer le diagramme (Normal / 180° / bidirectionnel) ne revient plus à l'ancien au bout de quelques secondes. Le diagramme commandé est maintenu tant que les moteurs bougent, plus une fenêtre de grâce après leur arrêt — le poll de statut distant, lent et en retard sur l'antenne, ne peut donc plus faire revenir l'affichage pendant que le changement est en cours.",
|
||||||
|
"QSL Manager : fermer la fenêtre — ou lancer un autre téléchargement — annule désormais celui en cours, une synchro QRZ lente ne continue donc plus en arrière-plan à mélanger son journal à un téléchargement LoTW fraîchement lancé. Le récapitulatif QRZ indique aussi clairement que seuls les QSO confirmés sont traités (ex. « 7950 sur 26164 confirmés ») — un logbook QRZ complet contient beaucoup de QSO pas encore confirmés, rien n'est donc perdu même si l'ancien texte donnait l'impression qu'une fraction seulement était revenue. (Un « http 503 » sur LoTW est un autre sujet : serveur de l'ARRL indisponible — réessayez plus tard.)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.23.3",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Multiple rotors: Settings → Rotator is now a list, like the amplifiers — add rotors with “Add rotor”, remove with the trash icon. Mix any types (an ARCO and a Rotator Genius); a Rotator Genius set to “drives two rotors” adds a second one. The compass has a selector to pick the active rotor, which it turns, displays and points to the DX; each rotor is named, and you mark which one carries the motorized antenna (Ultrabeam/SteppIR) so the boom and reverse/bidirectional pattern paths show only for it.",
|
||||||
|
"CAT PTT hotkey: pick a key in Settings → CAT and it keys the transmitter over CAT while OpsLog is focused — hold-to-talk or toggle (a rig with no CAT link falls back to the Audio-tab RTS/DTR method). Keys that need AltGr on your layout (e.g. \"\\\" on AZERTY) work, and each press is written to the diagnostic log.",
|
||||||
|
"TCI spots (ExpertSDR / SunSDR): OpsLog's cluster spots now show on the panorama — the colour is sent as the decimal integer the protocol expects (a hex string was silently dropped), with a valid modulation — and clicking one fills the entry (callsign + frequency) via the CLICKED_ON_SPOT message.",
|
||||||
|
"FlexRadio spot click: clicking one of OpsLog's spots on the SmartSDR panadapter now switches the mode too, not just the frequency.",
|
||||||
|
"PowerGenius XL: the widget and the FlexRadio amplifier card now read forward power, drain current and temperature from the amp's own link (polled every 250 ms with peak-hold), so the meters keep working — and stay steady — over a remote link where the radio's VITA stream never reaches OpsLog. Adds a remote-access password (Settings → Amplifier) to reach the amp from outside the LAN, and fixes the fan mode (Standard/Contest/Broadcast), which was reverting to Contest.",
|
||||||
|
"CW macro <LOGQSO>: a macro ending in the token (e.g. \"BK UR <STX> TU <LOGQSO>\") now logs the QSO once the CW before it has been sent, not the instant the macro starts — it was logging on the first keyed letter.",
|
||||||
|
"Filter presets: the trash icon next to a saved preset deletes it again — clicking it was loading the preset instead of removing it.",
|
||||||
|
"Kenwood/Elecraft (K3/K4): data modes (FT8/PSK/JT) now go out on USB on every band — below 10 MHz they were landing on LSB, which a K3 shows as \"DATA REV\". The frequency refreshes promptly again (the split double-check no longer runs on every poll). And a new CW keyer engine (Settings → CW Keyer → Kenwood/Elecraft) sends CW over the CAT link with the KY command — no WinKeyer and no second COM port — for a rig whose single USB port is the CAT port. Needs on-air testing on a K3."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Plusieurs rotors : Réglages → Rotator est désormais une liste, comme les amplis — ajoutez des rotors avec « Ajouter un rotor », supprimez avec la corbeille. Mélangez les types (un ARCO et un Rotator Genius) ; un Rotator Genius réglé sur « pilote deux rotors » en ajoute un second. Le compas a un sélecteur pour choisir le rotor actif, qu'il tourne, affiche et pointe vers le DX ; chaque rotor est nommé, et vous indiquez lequel porte l'antenne motorisée (Ultrabeam/SteppIR) pour que les tracés de boom et de diagramme inverse/bidirectionnel n'apparaissent que pour lui.",
|
||||||
|
"Touche PTT CAT : choisissez une touche dans Réglages → CAT et elle passe la radio en émission via CAT quand OpsLog a le focus — maintien ou bascule (une radio sans liaison CAT retombe sur la méthode RTS/DTR de l'onglet Audio). Les touches nécessitant AltGr (ex. « \\ » en AZERTY) fonctionnent, et chaque appui est tracé dans le journal de diagnostic.",
|
||||||
|
"Spots TCI (ExpertSDR / SunSDR) : les spots cluster d'OpsLog apparaissent sur le panorama — la couleur est envoyée en entier décimal attendu par le protocole (une chaîne hexadécimale était silencieusement rejetée), avec une modulation valide — et cliquer un spot remplit la saisie (indicatif + fréquence) via le message CLICKED_ON_SPOT.",
|
||||||
|
"Clic sur un spot FlexRadio : cliquer un spot d'OpsLog sur le panadapter SmartSDR change désormais aussi le mode, plus seulement la fréquence.",
|
||||||
|
"PowerGenius XL : le widget et la carte ampli du panneau FlexRadio lisent maintenant puissance directe, courant de drain et température depuis la liaison propre de l'ampli (interrogée toutes les 250 ms avec maintien de crête) — les meters continuent donc de fonctionner, et restent stables, sur une liaison distante où le flux VITA de la radio n'atteint jamais OpsLog. Ajoute un mot de passe d'accès distant (Réglages → Amplificateur) pour joindre l'ampli hors du réseau local, et corrige le mode ventilateur (Standard/Contest/Broadcast) qui repassait sur Contest.",
|
||||||
|
"Macro CW <LOGQSO> : une macro se terminant par le token (ex. « BK UR <STX> TU <LOGQSO> ») journalise le QSO une fois le CW qui précède envoyé, et non à l'instant où la macro démarre — elle journalisait dès la première lettre manipulée.",
|
||||||
|
"Presets de filtre : la corbeille à côté d'un preset enregistré le supprime de nouveau — le clic chargeait le preset au lieu de le retirer.",
|
||||||
|
"Kenwood/Elecraft (K3/K4) : les modes data (FT8/PSK/JT) partent désormais en USB sur toutes les bandes — sous 10 MHz ils passaient en LSB, ce qu'un K3 affiche comme « DATA REV ». La fréquence se rafraîchit de nouveau rapidement (la double-vérification du split ne se fait plus à chaque cycle). Et un nouveau moteur de keyer (Réglages → Keyer CW → Kenwood/Elecraft) envoie le CW sur la liaison CAT avec la commande KY — sans WinKeyer ni second port COM — pour une radio dont l'unique port USB est le port CAT. À tester sur l'air avec un K3."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
+149
-25
@@ -20,7 +20,7 @@ import {
|
|||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
|
||||||
GetSecretStatus, UnlockSecrets,
|
GetSecretStatus, UnlockSecrets,
|
||||||
RefreshCtyDat, DownloadAllReferenceLists,
|
RefreshCtyDat, DownloadAllReferenceLists,
|
||||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
RotatorGoTo, RotatorStop, GetRotatorHeading, SetActiveRotor,
|
||||||
GetDBConnectionInfo, GetLogbookRevision,
|
GetDBConnectionInfo, GetLogbookRevision,
|
||||||
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
|
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
|
||||||
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
OpenExternalURL,
|
OpenExternalURL,
|
||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||||
GetCATSettings,
|
GetCATSettings, PTTHotkeyDown, PTTHotkeyUp,
|
||||||
GetSolarData,
|
GetSolarData,
|
||||||
GetQSORate,
|
GetQSORate,
|
||||||
LoTWUserInfo,
|
LoTWUserInfo,
|
||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
||||||
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
||||||
IcomSendCW, YaesuSendCW, YaesuStopCW, SetYaesuKeySpeed, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
IcomSendCW, YaesuSendCW, YaesuStopCW, SetYaesuKeySpeed, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
||||||
|
KenwoodSendCW, KenwoodStopCW, SetKenwoodKeySpeed,
|
||||||
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
|
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
|
||||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||||
@@ -628,6 +629,11 @@ export default function App() {
|
|||||||
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||||
// in Preferences > Hardware > CAT interface.
|
// in Preferences > Hardware > CAT interface.
|
||||||
const digitalDefaultRef = useRef<string>('FT8');
|
const digitalDefaultRef = useRef<string>('FT8');
|
||||||
|
// PTT hotkey config, refreshed by loadCATCfg. Held in a ref so the global
|
||||||
|
// key listener (registered once) always sees the current binding.
|
||||||
|
const pttHotkeyRef = useRef<{ enabled: boolean; code: string; toggle: boolean }>({ enabled: false, code: '', toggle: false });
|
||||||
|
const pttHeldRef = useRef(false); // hold-mode: PTT currently keyed by the hotkey
|
||||||
|
const pttToggledRef = useRef(false); // toggle-mode: latched TX state
|
||||||
// Don't override freq/band/mode the user JUST typed — track a small grace
|
// Don't override freq/band/mode the user JUST typed — track a small grace
|
||||||
// window after manual edits and skip CAT updates during it.
|
// window after manual edits and skip CAT updates during it.
|
||||||
const catFreezeUntilRef = useRef<number>(0);
|
const catFreezeUntilRef = useRef<number>(0);
|
||||||
@@ -1075,7 +1081,7 @@ export default function App() {
|
|||||||
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
||||||
// auto-call and <LOGQSO> are shared; only the transport differs.
|
// auto-call and <LOGQSO> are shared; only the transport differs.
|
||||||
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
|
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
|
||||||
const cwSource: 'winkeyer' | 'icom' | 'flex' | 'yaesu' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : wkEngine === 'yaesu' ? 'yaesu' : 'winkeyer';
|
const cwSource: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : wkEngine === 'yaesu' ? 'yaesu' : wkEngine === 'kenwood' ? 'kenwood' : 'winkeyer';
|
||||||
// Setting the CW speed has to reach the keyer that is ACTUALLY sending, and
|
// Setting the CW speed has to reach the keyer that is ACTUALLY sending, and
|
||||||
// both the CW panel and the Yaesu console can ask for it. With DTR/RTS line
|
// both the CW panel and the Yaesu console can ask for it. With DTR/RTS line
|
||||||
// keying the PC does the timing, so the rig's internal keyer speed changes
|
// keying the PC does the timing, so the rig's internal keyer speed changes
|
||||||
@@ -1088,6 +1094,7 @@ export default function App() {
|
|||||||
if (src === 'icom') IcomSetKeySpeed(w).catch(() => {});
|
if (src === 'icom') IcomSetKeySpeed(w).catch(() => {});
|
||||||
else if (src === 'flex') FlexSetKeySpeed(w).catch(() => {});
|
else if (src === 'flex') FlexSetKeySpeed(w).catch(() => {});
|
||||||
else if (src === 'yaesu') SetYaesuKeySpeed(w).catch(() => {});
|
else if (src === 'yaesu') SetYaesuKeySpeed(w).catch(() => {});
|
||||||
|
else if (src === 'kenwood') SetKenwoodKeySpeed(w).catch(() => {});
|
||||||
else WinkeyerSetSpeed(w).catch(() => {});
|
else WinkeyerSetSpeed(w).catch(() => {});
|
||||||
// The rig's own keyer follows too whenever a Yaesu is on CAT, even when it is
|
// The rig's own keyer follows too whenever a Yaesu is on CAT, even when it is
|
||||||
// not the sending engine: its front panel and OpsLog then agree.
|
// not the sending engine: its front panel and OpsLog then agree.
|
||||||
@@ -1131,6 +1138,7 @@ export default function App() {
|
|||||||
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
|
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
|
||||||
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
|
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
|
||||||
: cwSource === 'yaesu' ? (catState.backend === 'yaesu' && catState.connected)
|
: cwSource === 'yaesu' ? (catState.backend === 'yaesu' && catState.connected)
|
||||||
|
: cwSource === 'kenwood' ? (catState.backend === 'kenwood' && catState.connected)
|
||||||
: wkStatus.connected;
|
: wkStatus.connected;
|
||||||
wkActiveRef.current = wkEnabled && connected;
|
wkActiveRef.current = wkEnabled && connected;
|
||||||
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
|
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
|
||||||
@@ -1504,6 +1512,11 @@ export default function App() {
|
|||||||
const [deletingIds, setDeletingIds] = useState<number[]>([]);
|
const [deletingIds, setDeletingIds] = useState<number[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
// Call/band/mode of the QSO highlighted in the Recent-QSOs grid. Drives the
|
||||||
|
// Stats (F1) matrix whenever the entry form is empty — clicking a past contact
|
||||||
|
// is the natural way to ask "what else have I got with this one?", and until
|
||||||
|
// now the panel just sat blank.
|
||||||
|
const [selQso, setSelQso] = useState<{ call: string; band: string; mode: string } | null>(null);
|
||||||
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
|
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
|
||||||
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
@@ -1764,23 +1777,28 @@ export default function App() {
|
|||||||
: null
|
: null
|
||||||
), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]);
|
), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]);
|
||||||
|
|
||||||
|
// Only the active rotor carrying the motorized antenna (Ultrabeam/SteppIR)
|
||||||
|
// shows the pattern paths; a plain rotor shows just its heading. The backend
|
||||||
|
// reports Motorized for the active rotor (true for a lone/standard rotor).
|
||||||
|
const activeRotorMotorized = (rotatorHeading as any).motorized !== false;
|
||||||
|
|
||||||
const beamHeadings = useMemo<number[]>(() => {
|
const beamHeadings = useMemo<number[]>(() => {
|
||||||
if (rotorAz == null) return [];
|
if (rotorAz == null) return [];
|
||||||
if (ubStatus.enabled && ubStatus.connected) {
|
if (activeRotorMotorized && ubStatus.enabled && ubStatus.connected) {
|
||||||
if (ubStatus.direction === 1) return [(rotorAz + 180) % 360];
|
if (ubStatus.direction === 1) return [(rotorAz + 180) % 360];
|
||||||
if (ubStatus.direction === 2) return [rotorAz, (rotorAz + 180) % 360];
|
if (ubStatus.direction === 2) return [rotorAz, (rotorAz + 180) % 360];
|
||||||
}
|
}
|
||||||
return [rotorAz];
|
return [rotorAz];
|
||||||
}, [rotorAz, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
}, [rotorAz, activeRotorMotorized, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
||||||
|
|
||||||
// Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can
|
// Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can
|
||||||
// show where the antenna physically points (boom) vs where it radiates when
|
// show where the antenna physically points (boom) vs where it radiates when
|
||||||
// the Ultrabeam is reversed/bidirectional.
|
// the Ultrabeam is reversed/bidirectional.
|
||||||
const boomHeading = rotorAz;
|
const boomHeading = rotorAz;
|
||||||
const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => {
|
const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => {
|
||||||
if (!(ubStatus.enabled && ubStatus.connected)) return null;
|
if (!activeRotorMotorized || !(ubStatus.enabled && ubStatus.connected)) return null;
|
||||||
return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal';
|
return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal';
|
||||||
}, [ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
}, [activeRotorMotorized, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
||||||
|
|
||||||
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
||||||
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
||||||
@@ -1927,6 +1945,56 @@ export default function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
|
|
||||||
|
// PTT hotkey: a keyboard key keys the rig while OpsLog is focused. Registered
|
||||||
|
// once; reads the live binding from pttHotkeyRef (kept current by loadCATCfg).
|
||||||
|
// Hold-to-talk by default; toggle mode latches. We swallow the key (so the
|
||||||
|
// dedicated PTT key doesn't type a character) and ignore auto-repeat.
|
||||||
|
useEffect(() => {
|
||||||
|
const down = (e: KeyboardEvent) => {
|
||||||
|
const cfg = pttHotkeyRef.current;
|
||||||
|
if (!cfg.enabled || !cfg.code || e.code !== cfg.code) return;
|
||||||
|
// Allow AltGr (Ctrl+Alt together) — French/other layouts need it to reach
|
||||||
|
// keys like "\" — but never hijack a real Ctrl- or Win-shortcut.
|
||||||
|
if (e.metaKey || (e.ctrlKey && !e.altKey)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.repeat) return;
|
||||||
|
// Surface the result: this feature was impossible to diagnose because every
|
||||||
|
// error was swallowed. The line names the key, the action and any failure.
|
||||||
|
const fail = (err: any) => { UILog(`ptt hotkey: ${e.code} FAILED: ${err}`).catch(() => {}); };
|
||||||
|
if (cfg.toggle) {
|
||||||
|
pttToggledRef.current = !pttToggledRef.current;
|
||||||
|
const on = pttToggledRef.current;
|
||||||
|
UILog(`ptt hotkey: ${e.code} toggle → ${on ? 'KEY' : 'UNKEY'}`).catch(() => {});
|
||||||
|
(on ? PTTHotkeyDown() : PTTHotkeyUp())?.catch?.(fail);
|
||||||
|
} else if (!pttHeldRef.current) {
|
||||||
|
pttHeldRef.current = true;
|
||||||
|
UILog(`ptt hotkey: ${e.code} down → KEY`).catch(() => {});
|
||||||
|
PTTHotkeyDown().catch(fail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const up = (e: KeyboardEvent) => {
|
||||||
|
const cfg = pttHotkeyRef.current;
|
||||||
|
if (!cfg.enabled || !cfg.code || e.code !== cfg.code) return;
|
||||||
|
if (cfg.toggle) return; // toggle unkeys on the next press, not on release
|
||||||
|
if (pttHeldRef.current) {
|
||||||
|
pttHeldRef.current = false;
|
||||||
|
PTTHotkeyUp();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Losing focus mid-hold would never deliver keyup — release so TX can't stick.
|
||||||
|
const blur = () => {
|
||||||
|
if (pttHeldRef.current) { pttHeldRef.current = false; PTTHotkeyUp(); }
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', down, true);
|
||||||
|
window.addEventListener('keyup', up, true);
|
||||||
|
window.addEventListener('blur', blur);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', down, true);
|
||||||
|
window.removeEventListener('keyup', up, true);
|
||||||
|
window.removeEventListener('blur', blur);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
||||||
// rotator is disabled (the backend just reads settings and returns).
|
// rotator is disabled (the backend just reads settings and returns).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2110,6 +2178,11 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const c = await GetCATSettings();
|
const c = await GetCATSettings();
|
||||||
if (c.digital_default) digitalDefaultRef.current = c.digital_default;
|
if (c.digital_default) digitalDefaultRef.current = c.digital_default;
|
||||||
|
pttHotkeyRef.current = {
|
||||||
|
enabled: !!(c as any).ptt_hotkey_enabled,
|
||||||
|
code: (c as any).ptt_hotkey ?? '',
|
||||||
|
toggle: !!(c as any).ptt_hotkey_toggle,
|
||||||
|
};
|
||||||
setCatBackend(c.backend ?? '');
|
setCatBackend(c.backend ?? '');
|
||||||
} catch {}
|
} catch {}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -2539,6 +2612,11 @@ export default function App() {
|
|||||||
const applyUdpCall = (raw: string, force = false): boolean => {
|
const applyUdpCall = (raw: string, force = false): boolean => {
|
||||||
const call = String(raw ?? '').trim();
|
const call = String(raw ?? '').trim();
|
||||||
if (!call) return false;
|
if (!call) return false;
|
||||||
|
// A callsign always has at least one letter. WSJT-X/JTDX (and relayed spot
|
||||||
|
// clicks) sometimes broadcast a bare number in the DX-call field — famously
|
||||||
|
// "1" — which would land in the entry and, on the next Enter, log a QSO
|
||||||
|
// with callsign "1". Reject anything with no letter.
|
||||||
|
if (!/[a-z]/i.test(call)) return false;
|
||||||
const upper = call.toUpperCase();
|
const upper = call.toUpperCase();
|
||||||
const current = callsignValRef.current.trim().toUpperCase();
|
const current = callsignValRef.current.trim().toUpperCase();
|
||||||
const prev = lastUdpCallRef.current;
|
const prev = lastUdpCallRef.current;
|
||||||
@@ -2571,6 +2649,11 @@ export default function App() {
|
|||||||
const call = String(p?.call ?? '');
|
const call = String(p?.call ?? '');
|
||||||
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
|
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
|
||||||
});
|
});
|
||||||
|
// Clicking a spot on the ExpertSDR (TCI) panorama fills the call, like Flex.
|
||||||
|
const unsubTciSpot = EventsOn('tci:spot_clicked', (p: any) => {
|
||||||
|
const call = String(p?.call ?? '');
|
||||||
|
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
|
||||||
|
});
|
||||||
const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
|
const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
|
||||||
const total = Number(p?.total ?? 0);
|
const total = Number(p?.total ?? 0);
|
||||||
const processed = Number(p?.processed ?? 0);
|
const processed = Number(p?.processed ?? 0);
|
||||||
@@ -2605,7 +2688,7 @@ export default function App() {
|
|||||||
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
||||||
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
||||||
});
|
});
|
||||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
|
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubTciSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -2633,6 +2716,7 @@ export default function App() {
|
|||||||
s.engine === 'icom' ? 'icom'
|
s.engine === 'icom' ? 'icom'
|
||||||
: s.engine === 'flex' ? 'flex'
|
: s.engine === 'flex' ? 'flex'
|
||||||
: s.engine === 'yaesu' ? 'yaesu'
|
: s.engine === 'yaesu' ? 'yaesu'
|
||||||
|
: s.engine === 'kenwood' ? 'kenwood'
|
||||||
: s.engine === 'serial' ? 'serial'
|
: s.engine === 'serial' ? 'serial'
|
||||||
: 'winkeyer');
|
: 'winkeyer');
|
||||||
} catch { /* keyer not configured */ }
|
} catch { /* keyer not configured */ }
|
||||||
@@ -2760,7 +2844,7 @@ export default function App() {
|
|||||||
// segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
|
// segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
|
||||||
// variables correctly.
|
// variables correctly.
|
||||||
const parts = rawText.split(/<LOGQSO>/i).map((pt) => resolveCW(pt));
|
const parts = rawText.split(/<LOGQSO>/i).map((pt) => resolveCW(pt));
|
||||||
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex' || cwSourceRef.current === 'yaesu';
|
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex' || cwSourceRef.current === 'yaesu' || cwSourceRef.current === 'kenwood';
|
||||||
for (let p = 0; p < parts.length; p++) {
|
for (let p = 0; p < parts.length; p++) {
|
||||||
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
|
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
|
||||||
const resolved = parts[p];
|
const resolved = parts[p];
|
||||||
@@ -2772,15 +2856,14 @@ export default function App() {
|
|||||||
// current WPM, so it scales automatically.
|
// current WPM, so it scales automatically.
|
||||||
const keyed = resolved + ' ';
|
const keyed = resolved + ' ';
|
||||||
setWkSent(resolved);
|
setWkSent(resolved);
|
||||||
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : cwSourceRef.current === 'yaesu' ? YaesuSendCW : null;
|
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : cwSourceRef.current === 'yaesu' ? YaesuSendCW : cwSourceRef.current === 'kenwood' ? KenwoodSendCW : null;
|
||||||
if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
|
if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||||
else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||||
// Only WAIT for the CW to finish on the FINAL segment — auto-call needs the
|
// WAIT for THIS segment's CW to finish before moving on — so a <LOGQSO>
|
||||||
// send done before its gap+resend. A segment a <LOGQSO> follows is NOT waited
|
// that follows logs AFTER the text before it (e.g. "TU") has actually been
|
||||||
// on: the keyer has already buffered the CW, so logging happens AT ONCE and
|
// sent, not the instant it's buffered into the keyer. Buffering then logging
|
||||||
// never interrupts what's being sent. (Gating the log on a CW-length ESTIMATE
|
// "at once" made a macro ending in "TU <LOGQSO>" log on the very first
|
||||||
// made <LOGQSO> log up to ~10s late when the estimate ran long.)
|
// letter. Auto-call also needs the send finished before its gap+resend.
|
||||||
if (isLast) {
|
|
||||||
if (isRig) {
|
if (isRig) {
|
||||||
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
|
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
|
||||||
} else {
|
} else {
|
||||||
@@ -2802,9 +2885,8 @@ export default function App() {
|
|||||||
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (aborted()) return; // aborted while this segment was sending → don't log
|
if (aborted()) return; // aborted while this segment was sending → don't log
|
||||||
if (logAfter) void save(); // log NOW — the buffered CW keeps sending
|
if (logAfter) void save(); // the preceding CW has finished → log at the token's spot
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// stopAutoCall cancels any running auto-call loop.
|
// stopAutoCall cancels any running auto-call loop.
|
||||||
@@ -2816,6 +2898,7 @@ export default function App() {
|
|||||||
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
||||||
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||||
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
|
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
|
||||||
|
else if (cwSourceRef.current === 'kenwood') KenwoodStopCW().catch(() => {});
|
||||||
else WinkeyerStop().catch(() => {});
|
else WinkeyerStop().catch(() => {});
|
||||||
}
|
}
|
||||||
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
|
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
|
||||||
@@ -3237,6 +3320,24 @@ export default function App() {
|
|||||||
finally { setDeletingAll(false); }
|
finally { setDeletingAll(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Worked-before for the QSO highlighted in the grid. Deliberately NOT
|
||||||
|
// runWorkedBefore: that one owns the entry form's wbRef and can trigger a
|
||||||
|
// field backfill, which browsing the log must never do.
|
||||||
|
const [selWb, setSelWb] = useState<WB | null>(null);
|
||||||
|
const [selWbBusy, setSelWbBusy] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const call = (selQso?.call ?? '').trim().toUpperCase();
|
||||||
|
// The entry form wins whenever it has a call — it's the live QSO.
|
||||||
|
if (!call || callsign.trim()) { setSelWb(null); return; }
|
||||||
|
let dead = false;
|
||||||
|
setSelWbBusy(true);
|
||||||
|
WorkedBefore(call, 0)
|
||||||
|
.then((w: any) => { if (!dead) setSelWb(w); })
|
||||||
|
.catch(() => { if (!dead) setSelWb(null); })
|
||||||
|
.finally(() => { if (!dead) setSelWbBusy(false); });
|
||||||
|
return () => { dead = true; };
|
||||||
|
}, [selQso, callsign]);
|
||||||
|
|
||||||
async function runWorkedBefore(call: string, dxccHint: number = 0) {
|
async function runWorkedBefore(call: string, dxccHint: number = 0) {
|
||||||
setWbBusy(true);
|
setWbBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -3743,6 +3844,7 @@ export default function App() {
|
|||||||
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
||||||
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
|
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
|
||||||
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||||
|
else if (cwSourceRef.current === 'kenwood') KenwoodStopCW().catch(() => {});
|
||||||
else WinkeyerStop().catch(() => {});
|
else WinkeyerStop().catch(() => {});
|
||||||
}
|
}
|
||||||
if (!keyerLive || wkEscClearsRef.current) {
|
if (!keyerLive || wkEscClearsRef.current) {
|
||||||
@@ -3780,6 +3882,16 @@ export default function App() {
|
|||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o') {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o') {
|
||||||
e.preventDefault(); importAdif(); return;
|
e.preventDefault(); importAdif(); return;
|
||||||
}
|
}
|
||||||
|
// Alt+W: wipe the entry. The near-universal logger convention (N1MM,
|
||||||
|
// Log4OM, DXLog), and unlike ESC it is never swallowed by the CW keyer —
|
||||||
|
// so there is always one key that clears, whatever else is running.
|
||||||
|
// Alt-combos carry no character on Windows, so this is safe while typing.
|
||||||
|
if (e.altKey && !e.ctrlKey && !e.metaKey && e.key.toLowerCase() === 'w') {
|
||||||
|
e.preventDefault();
|
||||||
|
resetEntry();
|
||||||
|
callsignRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (typing) return;
|
if (typing) return;
|
||||||
if (selectedId !== null) {
|
if (selectedId !== null) {
|
||||||
if (e.key === 'Delete') { e.preventDefault(); askDeleteSelected(); return; }
|
if (e.key === 'Delete') { e.preventDefault(); askDeleteSelected(); return; }
|
||||||
@@ -4052,7 +4164,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col flex-1 min-w-[130px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
|
<div className="flex flex-col flex-1 min-w-[80px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
|
||||||
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
|
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -4281,7 +4393,7 @@ export default function App() {
|
|||||||
title={catState.connected ? t('field.freqTuneHint') : undefined}
|
title={catState.connected ? t('field.freqTuneHint') : undefined}
|
||||||
onFocus={() => setFreqFocused(true)}
|
onFocus={() => setFreqFocused(true)}
|
||||||
onBlur={() => setFreqFocused(false)}
|
onBlur={() => setFreqFocused(false)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') tuneRadioTo(freqMhz); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); tuneRadioTo(freqMhz); } }}
|
||||||
onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }}
|
onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -4296,7 +4408,7 @@ export default function App() {
|
|||||||
title={catState.connected ? t('field.freqTuneHint') : undefined}
|
title={catState.connected ? t('field.freqTuneHint') : undefined}
|
||||||
onFocus={() => setFreqFocused(true)}
|
onFocus={() => setFreqFocused(true)}
|
||||||
onBlur={() => setFreqFocused(false)}
|
onBlur={() => setFreqFocused(false)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') tuneRadioTo(rxFreqMhz); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); tuneRadioTo(rxFreqMhz); } }}
|
||||||
onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }}
|
onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }}
|
||||||
className={cn('font-mono', catState.split && 'bg-danger-muted/40 border-danger-border focus:bg-card')}
|
className={cn('font-mono', catState.split && 'bg-danger-muted/40 border-danger-border focus:bg-card')}
|
||||||
/>
|
/>
|
||||||
@@ -4333,7 +4445,7 @@ export default function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button type="button" size="sm" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8 px-2.5 text-xs"
|
<Button type="button" size="sm" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8 px-2.5 text-xs"
|
||||||
title="Clear the QSO entry (always — unlike Esc which may be reserved for the CW keyer)">
|
title="Clear the QSO entry — Alt+W (always works, unlike Esc which may be reserved for the CW keyer)">
|
||||||
<Eraser className="size-3.5" />
|
<Eraser className="size-3.5" />
|
||||||
{t('btn.clear')}
|
{t('btn.clear')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -4667,6 +4779,7 @@ export default function App() {
|
|||||||
onExportFiltered={exportFilteredADIF}
|
onExportFiltered={exportFilteredADIF}
|
||||||
onDelete={(ids) => setDeletingIds(ids)}
|
onDelete={(ids) => setDeletingIds(ids)}
|
||||||
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||||
|
onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? "") } : null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -5404,7 +5517,10 @@ export default function App() {
|
|||||||
{/* Row 2: Name fixed to the Band/Mode/Country column width (300px) so
|
{/* Row 2: Name fixed to the Band/Mode/Country column width (300px) so
|
||||||
its right edge lines up with that column below; QTH grows to fill.
|
its right edge lines up with that column below; QTH grows to fill.
|
||||||
gap-4 matches Row 3 so QTH's left edge aligns with Comment/Note. */}
|
gap-4 matches Row 3 so QTH's left edge aligns with Comment/Note. */}
|
||||||
<div className="flex gap-4 items-end">
|
{/* flex-wrap, not overflow: at the narrowest window the Grid box used
|
||||||
|
to be pushed outside the entry panel and clipped. Wrapping drops
|
||||||
|
it to its own line instead, which stays readable. */}
|
||||||
|
<div className="flex gap-4 items-end flex-wrap">
|
||||||
<div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
|
<div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
|
||||||
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
|
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -5470,6 +5586,10 @@ export default function App() {
|
|||||||
band={band}
|
band={band}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
bands={bands}
|
bands={bands}
|
||||||
|
{...(!callsign.trim() && selQso ? {
|
||||||
|
slotCall: selQso.call, slotBand: selQso.band, slotMode: selQso.mode,
|
||||||
|
slotWb: selWb, slotWbBusy: selWbBusy,
|
||||||
|
} : {})}
|
||||||
tab={detailTab}
|
tab={detailTab}
|
||||||
onTab={setDetailTab}
|
onTab={setDetailTab}
|
||||||
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
|
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
|
||||||
@@ -5552,6 +5672,9 @@ export default function App() {
|
|||||||
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
|
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
|
||||||
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
|
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
|
||||||
rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
|
rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
|
||||||
|
rotors={(rotatorHeading as any).rotors}
|
||||||
|
activeRotor={(rotatorHeading as any).active}
|
||||||
|
onSelectRotor={(i) => { SetActiveRotor(i).then(() => GetRotatorHeading()).then((h: any) => setRotatorHeading(h)).catch((err) => setError(String(err?.message ?? err))); }}
|
||||||
onGoto={(az) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)))}
|
onGoto={(az) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)))}
|
||||||
onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
|
onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
|
||||||
/>
|
/>
|
||||||
@@ -5626,11 +5749,11 @@ export default function App() {
|
|||||||
// exactly when its CAT backend is. Yaesu was missing from this
|
// exactly when its CAT backend is. Yaesu was missing from this
|
||||||
// list, so the panel fell back to the WinKeyer status — which
|
// list, so the panel fell back to the WinKeyer status — which
|
||||||
// reported disconnected, no WinKeyer being attached.
|
// reported disconnected, no WinKeyer being attached.
|
||||||
status={cwSource === 'icom' || cwSource === 'flex' || cwSource === 'yaesu'
|
status={cwSource === 'icom' || cwSource === 'flex' || cwSource === 'yaesu' || cwSource === 'kenwood'
|
||||||
? {
|
? {
|
||||||
connected: catState.backend === cwSource && catState.connected,
|
connected: catState.backend === cwSource && catState.connected,
|
||||||
busy: false, wpm: wkWpm, version: 0,
|
busy: false, wpm: wkWpm, version: 0,
|
||||||
port: cwSource === 'flex' ? 'CWX' : cwSource === 'yaesu' ? 'CAT' : 'CI-V',
|
port: cwSource === 'flex' ? 'CWX' : cwSource === 'yaesu' || cwSource === 'kenwood' ? 'CAT' : 'CI-V',
|
||||||
}
|
}
|
||||||
: wkStatus}
|
: wkStatus}
|
||||||
ports={wkPorts}
|
ports={wkPorts}
|
||||||
@@ -5917,6 +6040,7 @@ export default function App() {
|
|||||||
onExportCabrilloFiltered={exportFilteredCabrillo}
|
onExportCabrilloFiltered={exportFilteredCabrillo}
|
||||||
onDelete={(ids) => setDeletingIds(ids)}
|
onDelete={(ids) => setDeletingIds(ids)}
|
||||||
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||||
|
onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? "") } : null)}
|
||||||
/>
|
/>
|
||||||
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
|
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -189,13 +189,20 @@ function AmpBlock({ amp, flex, showName, t }: {
|
|||||||
const txing = typeof flex?.transmitting === 'boolean'
|
const txing = typeof flex?.transmitting === 'boolean'
|
||||||
? flex.transmitting
|
? flex.transmitting
|
||||||
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
|
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
|
||||||
|
// Prefer the radio's VITA meter stream when it is actually flowing (local or
|
||||||
|
// over a VPN) — it is fast and matches SmartSDR. Fall back to the amp's OWN
|
||||||
|
// link only when the radio isn't streaming its meters (a remote link without
|
||||||
|
// the VITA path), so the reading survives but is naturally slower.
|
||||||
const liveW = flexW > 0 ? flexW : (pg.connected ? Number(pg.fwd_w) || 0 : 0);
|
const liveW = flexW > 0 ? flexW : (pg.connected ? Number(pg.fwd_w) || 0 : 0);
|
||||||
const fwdW = hold('fwd', txing ? liveW : 0, txing);
|
const fwdW = hold('fwd', txing ? liveW : 0, txing);
|
||||||
const id = meter(/^ID$|current/i);
|
const id = meter(/^ID$|current/i);
|
||||||
// Live drain current, for the same reason: peak_id latches like peak_w.
|
// Live drain current, same source preference. peak_id latches like peak_w, so
|
||||||
|
// use the instantaneous 'id' and gate it on the radio's transmit flag.
|
||||||
const idA = id ? Number(id.value) : (txing ? Number(pg.id) || undefined : 0);
|
const idA = id ? Number(id.value) : (txing ? Number(pg.id) || undefined : 0);
|
||||||
const temp = meter(/temp/i);
|
const temp = meter(/temp/i);
|
||||||
const tempC = pg.temperature > 0 ? pg.temperature : (temp ? temp.value : undefined);
|
// VITA temp matches SmartSDR (the amp's direct link exposes a different sensor
|
||||||
|
// that can read ~10°C higher); fall back to the direct temp when no VITA.
|
||||||
|
const tempC = temp ? temp.value : (pg.temperature > 0 ? pg.temperature : undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col gap-1.5">
|
<div className="h-full flex flex-col gap-1.5">
|
||||||
|
|||||||
@@ -211,11 +211,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
// Alicante" — which reference order scatters across the whole table.
|
// Alicante" — which reference order scatters across the whole table.
|
||||||
// Remembered through writeUiPref rather than localStorage, so the choice
|
// Remembered through writeUiPref rather than localStorage, so the choice
|
||||||
// travels with a copied data/ folder like every other UI preference here.
|
// travels with a copied data/ folder like every other UI preference here.
|
||||||
const [refSort, setRefSort] = useState<'ref' | 'name'>(
|
const [refSort, setRefSort] = useState<'ref' | 'name' | 'group'>(
|
||||||
() => (localStorage.getItem('opslog.awardRefSort') === 'name' ? 'name' : 'ref'));
|
() => {
|
||||||
|
const v = localStorage.getItem('opslog.awardRefSort');
|
||||||
|
return v === 'name' || v === 'group' ? v : 'ref';
|
||||||
|
});
|
||||||
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
|
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
|
||||||
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
|
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
|
||||||
const toggleRefSort = (k: 'ref' | 'name') => {
|
const toggleRefSort = (k: 'ref' | 'name' | 'group') => {
|
||||||
if (k === refSort) {
|
if (k === refSort) {
|
||||||
const d = refSortDir === 'asc' ? 'desc' : 'asc';
|
const d = refSortDir === 'asc' ? 'desc' : 'asc';
|
||||||
setRefSortDir(d);
|
setRefSortDir(d);
|
||||||
@@ -258,10 +261,26 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
if (c !== 0) return c * dir;
|
if (c !== 0) return c * dir;
|
||||||
return a.ref.localeCompare(b.ref) * dir; // stable tie-break
|
return a.ref.localeCompare(b.ref) * dir; // stable tie-break
|
||||||
}
|
}
|
||||||
|
if (refSort === 'group') {
|
||||||
|
// Prefix order (XE, YV, ZL…) — how a DX operator actually thinks about
|
||||||
|
// entities. Blank prefixes sink to the bottom regardless of direction,
|
||||||
|
// so an unresolved entity never heads the list.
|
||||||
|
const ag = a.group ?? '', bg = b.group ?? '';
|
||||||
|
if (!ag !== !bg) return ag ? -1 : 1;
|
||||||
|
const c = ag.localeCompare(bg, undefined, { sensitivity: 'base' });
|
||||||
|
if (c !== 0) return c * dir;
|
||||||
|
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||||
|
}
|
||||||
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||||
});
|
});
|
||||||
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
|
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
|
||||||
|
|
||||||
|
// The group column earns its width only when the list actually carries one
|
||||||
|
// (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the
|
||||||
|
// value IS the primary prefix, so it is labelled as such.
|
||||||
|
const hasGroup = useMemo(() => filteredRefs.some((r) => !!r.group), [filteredRefs]);
|
||||||
|
const groupColLabel = current && current.code.toUpperCase() === 'DXCC' ? t('awp.prefixCol') : t('awp.groupCol');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0">
|
<div className="flex h-full min-h-0">
|
||||||
{/* Award list */}
|
{/* Award list */}
|
||||||
@@ -459,6 +478,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
|
{hasGroup && (
|
||||||
|
<th className="text-left py-1.5 pr-3 font-medium border-b border-border w-20">
|
||||||
|
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{groupColLabel}
|
||||||
|
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">
|
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">
|
||||||
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
{t('awp.description')}
|
{t('awp.description')}
|
||||||
@@ -474,9 +501,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
{filteredRefs.map((r) => (
|
{filteredRefs.map((r) => (
|
||||||
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
|
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
|
||||||
<td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
|
<td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
|
||||||
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">
|
{hasGroup && (
|
||||||
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
|
<td className="py-1 pr-3 font-mono font-semibold border-b border-border/30">{r.group}</td>
|
||||||
</td>
|
)}
|
||||||
|
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">{r.name}</td>
|
||||||
{gridBands.map((b) => {
|
{gridBands.map((b) => {
|
||||||
const s = cellStatus(r, b);
|
const s = cellStatus(r, b);
|
||||||
return (
|
return (
|
||||||
@@ -513,7 +541,12 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
|
<th className="py-1.5 pr-2 font-medium w-40">
|
||||||
|
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{groupColLabel}
|
||||||
|
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
|
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
|
||||||
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Star, Radio, Sunrise, Sunset } from 'lucide-react';
|
import { Star, Radio, Sunrise, Sunset, X, Loader2 } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { sunTimes } from '@/lib/sun';
|
import { sunTimes } from '@/lib/sun';
|
||||||
|
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
|
||||||
import type { WorkedBeforeView } from '@/types';
|
import type { WorkedBeforeView } from '@/types';
|
||||||
|
|
||||||
type WorkedBefore = WorkedBeforeView;
|
type WorkedBefore = WorkedBeforeView;
|
||||||
@@ -18,6 +19,9 @@ interface Props {
|
|||||||
// to an entity with no position at all, and the block simply does not appear.
|
// to an entity with no position at all, and the block simply does not appear.
|
||||||
lat?: number;
|
lat?: number;
|
||||||
lon?: number;
|
lon?: number;
|
||||||
|
// Set when the matrix is showing a QSO picked in the log grid rather than the
|
||||||
|
// entry form — labelled so the two can never be confused.
|
||||||
|
forCall?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
|
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
|
||||||
@@ -89,7 +93,9 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
|
|||||||
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon }: Props) {
|
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall }: Props) {
|
||||||
|
// Cell drill-down: which band+class the operator clicked, or null.
|
||||||
|
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
|
||||||
// Columns from the operator's configured bands (so the matrix shows only the
|
// Columns from the operator's configured bands (so the matrix shows only the
|
||||||
// bands they actually use), falling back to the built-in default set.
|
// bands they actually use), falling back to the built-in default set.
|
||||||
const cols = useMemo(
|
const cols = useMemo(
|
||||||
@@ -208,6 +214,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
</>
|
</>
|
||||||
) : hasDxcc ? (
|
) : hasDxcc ? (
|
||||||
<>
|
<>
|
||||||
|
{/* Says WHOSE stats these are when they come from a row picked in
|
||||||
|
the log rather than from what's being typed. */}
|
||||||
|
{forCall && (
|
||||||
|
<Badge variant="outline" className="px-2 py-0.5 text-[10px] font-mono shrink-0"
|
||||||
|
title="Stats for the QSO selected in the log">
|
||||||
|
{forCall}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
||||||
{dxccName || `DXCC #${dxcc}`}
|
{dxccName || `DXCC #${dxcc}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -282,10 +296,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={b.tag}
|
key={b.tag}
|
||||||
title={cellTitle(b.tag, cls, st, isCurrent)}
|
title={cellTitle(b.tag, cls, st, isCurrent) + (st ? ' — click to list the QSOs' : '')}
|
||||||
|
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-[28px] h-[24px] rounded transition-colors p-0',
|
'w-[28px] h-[24px] rounded transition-colors p-0',
|
||||||
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
||||||
|
// Only a filled cell has anything to show — an empty one
|
||||||
|
// stays inert rather than opening a "no QSOs" dialog.
|
||||||
|
st && 'cursor-pointer hover:brightness-110',
|
||||||
isCurrent && 'ring-2 ring-warning ring-inset',
|
isCurrent && 'ring-2 ring-warning ring-inset',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -313,6 +331,107 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{slot && (
|
||||||
|
<SlotQSOModal
|
||||||
|
call={wb?.callsign ?? ''}
|
||||||
|
dxcc={dxcc}
|
||||||
|
entity={dxccName}
|
||||||
|
band={slot.band}
|
||||||
|
cls={slot.cls}
|
||||||
|
onClose={() => setSlot(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cell drill-down ──────────────────────────────────────────────────────────
|
||||||
|
// The contacts behind one band+class cell: this exact callsign AND anyone else
|
||||||
|
// in the entity, because that is the pair of facts the cell's colour encodes.
|
||||||
|
// The call's own QSOs are marked so the two never blur together.
|
||||||
|
|
||||||
|
function SlotQSOModal({ call, dxcc, entity, band, cls, onClose }: {
|
||||||
|
call: string; dxcc: number; entity: string; band: string; cls: string; onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState<any[] | null>(null);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let dead = false;
|
||||||
|
BandSlotQSOs(call, dxcc, band, cls)
|
||||||
|
.then((r: any) => { if (!dead) setRows((r ?? []) as any[]); })
|
||||||
|
.catch((e: any) => { if (!dead) { setErr(String(e?.message ?? e)); setRows([]); } });
|
||||||
|
return () => { dead = true; };
|
||||||
|
}, [call, dxcc, band, cls]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Capture phase: the app's global ESC handler resets the entry form, and
|
||||||
|
// closing a dialog must not also wipe what the operator was typing.
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') { e.stopImmediatePropagation(); e.preventDefault(); onClose(); }
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey, true);
|
||||||
|
return () => window.removeEventListener('keydown', onKey, true);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||||
|
<div className="bg-card border border-border rounded-lg shadow-xl w-[720px] max-w-[92vw] max-h-[70vh] flex flex-col"
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||||
|
<span className="font-semibold text-sm">
|
||||||
|
{band} · {cls}
|
||||||
|
{entity && <span className="text-muted-foreground font-normal"> — {entity}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
|
{rows ? `${rows.length} QSO` : ''}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{err && <p className="p-3 text-xs text-destructive">{err}</p>}
|
||||||
|
{!rows ? (
|
||||||
|
<p className="p-4 text-xs text-muted-foreground flex items-center gap-2">
|
||||||
|
<Loader2 className="size-3.5 animate-spin" /> Loading…
|
||||||
|
</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="p-4 text-xs text-muted-foreground italic">No QSOs.</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-muted/60 backdrop-blur text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left font-medium px-2 py-1">Date UTC</th>
|
||||||
|
<th className="text-left font-medium px-2 py-1">Callsign</th>
|
||||||
|
<th className="text-left font-medium px-2 py-1">Mode</th>
|
||||||
|
<th className="text-left font-medium px-2 py-1">Freq</th>
|
||||||
|
<th className="text-left font-medium px-2 py-1">Name</th>
|
||||||
|
<th className="text-left font-medium px-2 py-1">Cfm</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((q, i) => {
|
||||||
|
const cfm = q.lotw_rcvd === 'Y' || q.eqsl_rcvd === 'Y' || q.qsl_rcvd === 'Y';
|
||||||
|
return (
|
||||||
|
<tr key={q.id ?? i} className="border-t border-border/40">
|
||||||
|
<td className="px-2 py-1 tabular-nums text-muted-foreground whitespace-nowrap">
|
||||||
|
{String(q.qso_date ?? '').slice(0, 16).replace('T', ' ')}
|
||||||
|
</td>
|
||||||
|
<td className={cn('px-2 py-1 font-mono', q.callsign === call && 'font-bold text-primary')}>{q.callsign}</td>
|
||||||
|
<td className="px-2 py-1">{q.mode}</td>
|
||||||
|
<td className="px-2 py-1 tabular-nums text-muted-foreground">{q.freq_hz ? (q.freq_hz / 1e6).toFixed(3) : ''}</td>
|
||||||
|
<td className="px-2 py-1 text-muted-foreground truncate max-w-[160px]">{q.name}</td>
|
||||||
|
<td className="px-2 py-1">{cfm ? <span className="text-success">✓</span> : ''}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,6 +69,14 @@ interface Props {
|
|||||||
band: string;
|
band: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
bands?: string[]; // configured bands for the worked-before matrix columns
|
bands?: string[]; // configured bands for the worked-before matrix columns
|
||||||
|
// When the entry form is empty and a QSO is selected in the log grid, the
|
||||||
|
// Stats (F1) matrix shows THAT contact's entity instead of sitting blank.
|
||||||
|
// Only the matrix is redirected — every other tab still edits the live entry.
|
||||||
|
slotCall?: string;
|
||||||
|
slotBand?: string;
|
||||||
|
slotMode?: string;
|
||||||
|
slotWb?: any;
|
||||||
|
slotWbBusy?: boolean;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
onOpenImage?: () => void;
|
onOpenImage?: () => void;
|
||||||
// Optional controlled active tab (so the app can switch it via keyboard).
|
// Optional controlled active tab (so the app can switch it via keyboard).
|
||||||
@@ -139,7 +147,7 @@ function Field({ label, span = 1, className, children }: { label: string; span?:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||||
@@ -272,7 +280,14 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
|
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
|
||||||
{open === 'stats' && (
|
{open === 'stats' && (
|
||||||
<div className="px-3 py-2.5">
|
<div className="px-3 py-2.5">
|
||||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''}
|
<BandSlotGrid
|
||||||
|
wb={slotCall ? slotWb : wb}
|
||||||
|
busy={slotCall ? !!slotWbBusy : !!wbBusy}
|
||||||
|
currentBand={slotCall ? (slotBand ?? '') : band}
|
||||||
|
currentMode={slotCall ? (slotMode ?? '') : mode}
|
||||||
|
bands={bands}
|
||||||
|
hasCall={slotCall ? true : callsign.trim() !== ''}
|
||||||
|
forCall={slotCall}
|
||||||
lat={dxLL?.lat} lon={dxLL?.lon} />
|
lat={dxLL?.lat} lon={dxLL?.lon} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -234,7 +234,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
<SelectItem key={n} value={n}>
|
<SelectItem key={n} value={n}>
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
{n}
|
{n}
|
||||||
<Trash2 className="size-3 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); deletePreset(n); }} />
|
{/* Radix SelectItem selects on pointer-up, so an onClick here
|
||||||
|
fires too late — the menu has already loaded the preset and
|
||||||
|
closed. Intercept pointer-down (preventDefault stops Radix
|
||||||
|
claiming it) and delete there instead. */}
|
||||||
|
<Trash2
|
||||||
|
className="size-3 text-muted-foreground hover:text-destructive"
|
||||||
|
onPointerDown={(e) => { e.preventDefault(); e.stopPropagation(); deletePreset(n); }}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -328,7 +328,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
// longer and the meters visibly trail the end of a transmission, which reads
|
// longer and the meters visibly trail the end of a transmission, which reads
|
||||||
// as the app lagging rather than as a peak being held.
|
// as the app lagging rather than as a peak being held.
|
||||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||||
const peakHold = (key: string, val: number, windowMs = 1000) => {
|
const peakHold = (key: string, val: number, windowMs = 1000, live = true) => {
|
||||||
|
// live=false throws the held value away at once (e.g. the carrier dropped),
|
||||||
|
// so the meter falls immediately instead of coasting for the window.
|
||||||
|
if (!live) { delete peak.current[key]; return val; }
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const p = peak.current[key];
|
const p = peak.current[key];
|
||||||
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
|
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
|
||||||
@@ -362,7 +365,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
|
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
|
||||||
tick();
|
tick();
|
||||||
const id = window.setInterval(tick, 2000);
|
const id = window.setInterval(tick, 250); // match the amp's 250 ms poll so the card catches every envelope peak
|
||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1114,11 +1117,32 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
|
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
|
||||||
and shown compact so they sit with the amp controls. */}
|
and shown compact so they sit with the amp controls. */}
|
||||||
{(() => {
|
{(() => {
|
||||||
|
// Prefer the radio's VITA amp meters when they are flowing (local or
|
||||||
|
// over a VPN) — fast, and the same values SmartSDR shows (incl. the
|
||||||
|
// right temperature sensor). Fall back to the amp's OWN link only when
|
||||||
|
// the radio isn't streaming them (a remote link without VITA).
|
||||||
const meters = st.meters || [];
|
const meters = st.meters || [];
|
||||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
if (off || amp.length === 0) return null;
|
if (off || amp.length === 0) {
|
||||||
|
const P = pg as any;
|
||||||
|
if (!P.connected) return null;
|
||||||
|
// Direct-link fallback: a 500 ms poll samples the SSB envelope at
|
||||||
|
// random points, so peak-hold it (live=txing drops it the instant
|
||||||
|
// PTT releases). The LDMOS pair can pull well past 25 A → 50 A scale.
|
||||||
|
const txing = typeof st.transmitting === 'boolean' ? st.transmitting : /TRANSMIT/i.test(P.state || '');
|
||||||
|
const w = txing ? Math.round(Number(P.fwd_w) || 0) : 0;
|
||||||
|
const idA = txing ? Number(P.id) || 0 : 0;
|
||||||
|
const temp = Number(P.temperature) || 0;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
|
<MeterBar label={t('ampw.pwr')} value={peakHold('pgw', w, 2500, txing)} unit="W" lo={0} hi={2000} accent="#dc2626" />
|
||||||
|
<MeterBar label={t('ampw.id')} value={peakHold('pgid', idA, 2500, txing)} unit="A" lo={0} hi={50} accent="#16a34a" />
|
||||||
|
<MeterBar label={t('ampw.temp')} value={temp} unit="°C" lo={0} hi={80} accent="#ea580c" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
// Power comes from the radio's meter stream and nothing else. The
|
// Power comes from the radio's meter stream and nothing else. The
|
||||||
// amplifier also reports a "peakfwd", and using it was a mistake
|
// amplifier also reports a "peakfwd", and using it was a mistake
|
||||||
// twice over: it is a latched maximum that is never reset, and it
|
// twice over: it is a latched maximum that is never reset, and it
|
||||||
@@ -1133,14 +1157,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
}
|
}
|
||||||
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||||
// Drain current (ID): the PGXL reports a full-scale far too small
|
// Drain current (ID): the PGXL reports a full-scale far too small
|
||||||
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
|
// for this meter, so it pinned the bar. Give it a fixed 50 A scale
|
||||||
// value itself is fine — only the bar's range was wrong. Give it a
|
// (the LDMOS pair can pull ~45-50 A near full output), overriding an
|
||||||
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
|
// unusable reported range but keeping a sane one.
|
||||||
// only override an unusable reported range, not a sane one.
|
|
||||||
let lo = m.lo, hi = m.hi;
|
let lo = m.lo, hi = m.hi;
|
||||||
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
||||||
lo = 0;
|
lo = 0;
|
||||||
hi = m.hi >= 25 ? m.hi : 25;
|
hi = m.hi >= 50 ? m.hi : 50;
|
||||||
}
|
}
|
||||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ import { TailLogFile, GetLogFilePath } from '../../wailsjs/go/main/App';
|
|||||||
// 256 KB blob: with "cluster" or "acom" typed in, the window becomes exactly
|
// 256 KB blob: with "cluster" or "acom" typed in, the window becomes exactly
|
||||||
// the subsystem trace you would have grepped for.
|
// the subsystem trace you would have grepped for.
|
||||||
// • It polls only while open. A closed dialog costs nothing.
|
// • It polls only while open. A closed dialog costs nothing.
|
||||||
const TAIL_BYTES = 256 * 1024;
|
// 512 KB ≈ 3200 lines. The window is a sliding tail: once new lines push past it
|
||||||
|
// the oldest fall out of the fetched text entirely, so a small buffer dropped
|
||||||
|
// lines the operator was still reading during a busy trace (CAT/cluster/antenna
|
||||||
|
// polling). This is double the old 256 KB — 1 MB was tried but the per-second
|
||||||
|
// fetch + split + re-render of ~6500 lines made the window lag. 512 KB keeps a
|
||||||
|
// useful backlog while staying smooth. The backend caps this at 4 MB.
|
||||||
|
const TAIL_BYTES = 512 * 1024;
|
||||||
const POLL_MS = 1000;
|
const POLL_MS = 1000;
|
||||||
|
|
||||||
export function LogViewer({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
export function LogViewer({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
@@ -120,6 +120,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
|
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
|
||||||
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
||||||
}, [service]);
|
}, [service]);
|
||||||
|
// Closing the QSL Manager (the tab's ×) unmounts this panel — cancel any download
|
||||||
|
// still running so a slow QRZ/LoTW sync doesn't keep going in the background and
|
||||||
|
// bleed its log into the next one.
|
||||||
|
useEffect(() => () => { CancelConfirmations().catch(() => {}); }, []);
|
||||||
const [potaSyncing, setPotaSyncing] = useState(false);
|
const [potaSyncing, setPotaSyncing] = useState(false);
|
||||||
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
||||||
const [potaErr, setPotaErr] = useState('');
|
const [potaErr, setPotaErr] = useState('');
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ type Props = {
|
|||||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||||
onRowClicked?: (q: QSOForm) => void;
|
onRowClicked?: (q: QSOForm) => void;
|
||||||
onRowSelected?: (ids: number[]) => void;
|
onRowSelected?: (ids: number[]) => void;
|
||||||
|
// The full first selected row — the Stats (F1) panel shows its entity when the
|
||||||
|
// entry form is empty, so browsing the log answers "what have I got with this
|
||||||
|
// one?" without retyping the call.
|
||||||
|
onRowSelectedQso?: (row: QSOForm | null) => void;
|
||||||
onUpdateFromCty?: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
@@ -283,7 +287,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
|||||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
@@ -542,6 +546,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
setSelCount(sel.length);
|
setSelCount(sel.length);
|
||||||
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
|
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
|
||||||
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||||
|
onRowSelectedQso?.(sel[0] ?? null);
|
||||||
}
|
}
|
||||||
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ interface Props {
|
|||||||
centerLat?: number | null; // operator latitude (projection centre)
|
centerLat?: number | null; // operator latitude (projection centre)
|
||||||
centerLon?: number | null; // operator longitude
|
centerLon?: number | null; // operator longitude
|
||||||
rotorEnabled?: boolean;
|
rotorEnabled?: boolean;
|
||||||
|
rotors?: string[]; // logical rotor names; >1 → show a selector
|
||||||
|
activeRotor?: number; // index of the selected rotor (0-based)
|
||||||
|
onSelectRotor?: (i: number) => void; // switch the active rotor
|
||||||
onGoto?: (az: number) => void; // click-to-turn
|
onGoto?: (az: number) => void; // click-to-turn
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
@@ -38,7 +41,7 @@ function pt(az: number, radius: number): [number, number] {
|
|||||||
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
|
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose }: Props) {
|
||||||
const cardinals = useMemo(
|
const cardinals = useMemo(
|
||||||
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
||||||
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
||||||
@@ -100,6 +103,31 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Multiple rotors — pick which one the dial shows and turns. */}
|
||||||
|
{rotors && rotors.length > 1 && (
|
||||||
|
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
|
||||||
|
{rotors.map((nm, i) => {
|
||||||
|
const active = (activeRotor ?? 0) === i;
|
||||||
|
const label = nm?.trim() || `Rotor ${i + 1}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onSelectRotor?.(i)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 min-w-[48px] px-1.5 py-0.5 rounded text-[10px] font-semibold truncate transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-success text-success-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground hover:bg-muted/70',
|
||||||
|
)}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-2 min-h-0">
|
<div className="flex items-center justify-center p-2 min-h-0">
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
GetListsSettings, SaveListsSettings,
|
GetListsSettings, SaveListsSettings,
|
||||||
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||||
@@ -80,7 +80,7 @@ type StationSettings = StationSettingsForm;
|
|||||||
type ListsSettings = ListsSettingsForm;
|
type ListsSettings = ListsSettingsForm;
|
||||||
type ModePreset = ModePresetForm;
|
type ModePreset = ModePresetForm;
|
||||||
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
|
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
|
||||||
type RotatorSettings = Omit<mainModels.RotatorSettings, 'convertValues'>;
|
type RotatorDevice = Omit<mainModels.RotatorDevice, 'convertValues'>;
|
||||||
type ClusterServer = Omit<clusterModels.ServerConfig, 'convertValues'>;
|
type ClusterServer = Omit<clusterModels.ServerConfig, 'convertValues'>;
|
||||||
type ClusterServerStatus = Omit<clusterModels.ServerStatus, 'convertValues'>;
|
type ClusterServerStatus = Omit<clusterModels.ServerStatus, 'convertValues'>;
|
||||||
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||||||
@@ -1136,14 +1136,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [modeDraft, setModeDraft] = useState('');
|
const [modeDraft, setModeDraft] = useState('');
|
||||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
||||||
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
|
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
|
||||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||||
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
||||||
|
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
|
||||||
});
|
});
|
||||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
// While true, the next key press is captured as the PTT hotkey.
|
||||||
enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1,
|
const [capturingPtt, setCapturingPtt] = useState(false);
|
||||||
} as any);
|
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
|
||||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
@@ -1245,6 +1246,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||||
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
||||||
|
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
|
||||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||||
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
||||||
// Password-encryption (secret vault) state.
|
// Password-encryption (secret vault) state.
|
||||||
@@ -1498,7 +1500,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
try {
|
try {
|
||||||
const [l, ls, c, ap, r, b, qd, es] = await Promise.all([
|
const [l, ls, c, ap, r, b, qd, es] = await Promise.all([
|
||||||
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
|
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
|
||||||
GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
|
GetRotators(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
|
||||||
]);
|
]);
|
||||||
setLookup(l);
|
setLookup(l);
|
||||||
setActiveProfile(ap as Profile);
|
setActiveProfile(ap as Profile);
|
||||||
@@ -1511,7 +1513,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
await reloadProfiles();
|
await reloadProfiles();
|
||||||
await reloadClusterServers();
|
await reloadClusterServers();
|
||||||
setCatCfg(c);
|
setCatCfg(c);
|
||||||
setRotator(r);
|
setRotors((r ?? []) as any);
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
@@ -1552,7 +1554,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
try { setActiveProfile(await GetActiveProfile() as Profile); } catch {}
|
try { setActiveProfile(await GetActiveProfile() as Profile); } catch {}
|
||||||
try { setLookup(await GetLookupSettings() as any); } catch {}
|
try { setLookup(await GetLookupSettings() as any); } catch {}
|
||||||
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
||||||
try { setRotator(await GetRotatorSettings() as any); } catch {}
|
try { setRotors(((await GetRotators()) ?? []) as any); } catch {}
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
@@ -1744,7 +1746,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
await SaveLookupSettings(lookup as any);
|
await SaveLookupSettings(lookup as any);
|
||||||
await SaveCATSettings(catCfg as any);
|
await SaveCATSettings(catCfg as any);
|
||||||
await SaveRotatorSettings(rotator as any);
|
await SaveRotators(rotors as any);
|
||||||
await SaveUltrabeamSettings(ultrabeam as any);
|
await SaveUltrabeamSettings(ultrabeam as any);
|
||||||
await SaveAntGeniusSettings(antgenius as any);
|
await SaveAntGeniusSettings(antgenius as any);
|
||||||
await SaveTunerGeniusSettings(tunergenius as any);
|
await SaveTunerGeniusSettings(tunergenius as any);
|
||||||
@@ -2594,9 +2596,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={civTrace} onCheckedChange={(c) => { setCivTrace(!!c); SetCIVTrace(!!c); }} />
|
<Checkbox checked={civTrace} onCheckedChange={(c) => { setCivTrace(!!c); SetCIVTrace(!!c); }} />
|
||||||
{t('cat.civTrace')}
|
{t('cat.civTrace')}
|
||||||
</label>
|
</label> </div>
|
||||||
<p className="text-[10px] text-muted-foreground mt-1">{t('cat.civTraceHint')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{catCfg.backend === 'kenwood' && (
|
{catCfg.backend === 'kenwood' && (
|
||||||
<>
|
<>
|
||||||
@@ -2612,9 +2612,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</Select>
|
</Select>
|
||||||
<Button type="button" variant="outline" size="sm"
|
<Button type="button" variant="outline" size="sm"
|
||||||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
|
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
|
||||||
</div>
|
</div> </div>
|
||||||
<span className="text-xs text-muted-foreground">{t('cat.kenwoodPortHint')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('cat.baud')}</Label>
|
<Label>{t('cat.baud')}</Label>
|
||||||
<Select value={String(catCfg.kenwood_baud || 9600)} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_baud: parseInt(v) || 9600 }))}>
|
<Select value={String(catCfg.kenwood_baud || 9600)} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_baud: parseInt(v) || 9600 }))}>
|
||||||
@@ -2622,18 +2620,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
|
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select> </div>
|
||||||
<span className="text-xs text-muted-foreground">{t('cat.kenwoodBaudHint')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('cat.kenwoodHost')}</Label>
|
<Label>{t('cat.kenwoodHost')}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={catCfg.kenwood_host || ''}
|
value={catCfg.kenwood_host || ''}
|
||||||
placeholder="192.168.1.50:4532"
|
placeholder="192.168.1.50:4532"
|
||||||
onChange={(e) => setCatCfg((s) => ({ ...s, kenwood_host: e.target.value }))}
|
onChange={(e) => setCatCfg((s) => ({ ...s, kenwood_host: e.target.value }))}
|
||||||
/>
|
/> </div>
|
||||||
<span className="text-xs text-muted-foreground">{t('cat.kenwoodHostHint')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||||
<Checkbox checked={!!catCfg.kenwood_low_lines} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, kenwood_low_lines: !!c }))} />
|
<Checkbox checked={!!catCfg.kenwood_low_lines} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, kenwood_low_lines: !!c }))} />
|
||||||
@@ -2641,6 +2635,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</label>
|
</label>
|
||||||
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
|
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.kwDataMode')}</Label>
|
||||||
|
<Select value={(catCfg as any).kenwood_data_mode || 'usb'} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_data_mode: v } as any))}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="usb">{t('cat.kwDataUsb')}</SelectItem>
|
||||||
|
<SelectItem value="data">{t('cat.kwDataMd6')}</SelectItem>
|
||||||
|
<SelectItem value="keep">{t('cat.kwDataKeep')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select> </div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{catCfg.backend === 'icom' && (
|
{catCfg.backend === 'icom' && (
|
||||||
@@ -2811,6 +2815,52 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* PTT hotkey — a keyboard key that keys the rig while OpsLog is focused.
|
||||||
|
Uses the Audio → PTT method (CAT / RTS / DTR), falling back to CAT. */}
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={!!(catCfg as any).ptt_hotkey_enabled}
|
||||||
|
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, ptt_hotkey_enabled: !!c } as any))}
|
||||||
|
/>
|
||||||
|
{t('cat.pttKey')}
|
||||||
|
</label>
|
||||||
|
{(catCfg as any).ptt_hotkey_enabled && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (!capturingPtt) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.code === 'Escape') { setCapturingPtt(false); return; }
|
||||||
|
setCatCfg((s) => ({ ...s, ptt_hotkey: e.code } as any));
|
||||||
|
setCapturingPtt(false);
|
||||||
|
}}
|
||||||
|
onClick={() => setCapturingPtt(true)}
|
||||||
|
className="min-w-[140px] rounded-md border border-border bg-muted/40 px-3 py-1.5 text-sm font-mono hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
{capturingPtt ? t('cat.pttKeyPress') : ((catCfg as any).ptt_hotkey || t('cat.pttKeyNone'))}
|
||||||
|
</button>
|
||||||
|
{(catCfg as any).ptt_hotkey && !capturingPtt && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCatCfg((s) => ({ ...s, ptt_hotkey: '' } as any))}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t('cat.pttKeyClear')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={!!(catCfg as any).ptt_hotkey_toggle}
|
||||||
|
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, ptt_hotkey_toggle: !!c } as any))}
|
||||||
|
/>
|
||||||
|
{t('cat.pttKeyToggle')}
|
||||||
|
</label> </>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{catCfg.backend === 'omnirig' && (
|
{catCfg.backend === 'omnirig' && (
|
||||||
<>
|
<>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -2830,12 +2880,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testRotator() {
|
async function testRotator(dev: RotatorDevice, sub = 1) {
|
||||||
setRotatorTesting(true);
|
setRotatorTesting(true);
|
||||||
setRotatorTest(null);
|
setRotatorTest(null);
|
||||||
try {
|
try {
|
||||||
await TestRotator(rotator as any);
|
await TestRotatorDevice(dev as any, sub);
|
||||||
setRotatorTest({ ok: true, msg: (rotator as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
|
setRotatorTest({ ok: true, msg: (dev as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -3224,6 +3274,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* PowerGenius XL asks for a remote-access code when reached from
|
||||||
|
outside the LAN (banner "V… AUTH"), like the Tuner/Antenna Genius.
|
||||||
|
Blank on the LAN. */}
|
||||||
|
{isPGXL && (
|
||||||
|
<div className="space-y-1 max-w-xs">
|
||||||
|
<Label>{t('amp.password')}</Label>
|
||||||
|
<Input type="password" value={(amp as any).password ?? ''}
|
||||||
|
onChange={(e) => patchAmp(i, { password: e.target.value } as any)}
|
||||||
|
placeholder={t('amp.passwordPh')} className="font-mono" />
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('amp.passwordHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Band-follow, for any amp that takes its band from a transceiver
|
{/* Band-follow, for any amp that takes its band from a transceiver
|
||||||
CAT link (ACOM and SPE both do) — never PowerGenius, which is
|
CAT link (ACOM and SPE both do) — never PowerGenius, which is
|
||||||
@@ -3305,26 +3367,46 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RotatorPanel() {
|
function RotatorPanel() {
|
||||||
const isRG = (rotator as any).type === 'rotgenius';
|
// Rotors are a list, like amplifiers: add with +, remove with the trash icon.
|
||||||
const isARCO = (rotator as any).type === 'arco';
|
// A Rotator Genius with "two rotors" contributes 2 addressable rotors.
|
||||||
|
const patch = (i: number, p: Partial<RotatorDevice>) => setRotors((l) => l.map((d, j) => (j === i ? { ...d, ...p } : d)) as any);
|
||||||
|
const addRotor = () => setRotors((l) => [...l, {
|
||||||
|
id: '', name: '', type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false,
|
||||||
|
rotator_num: 1, dual: false, motorized: true, name2: '', motorized2: false,
|
||||||
|
transport: 'tcp', com_port: '', baud: 9600,
|
||||||
|
} as any]);
|
||||||
|
const removeRotor = (i: number) => setRotors((l) => l.filter((_, j) => j !== i));
|
||||||
|
const anyPst = rotors.some((d) => ((d as any).type ?? 'pst') === 'pst');
|
||||||
|
// The motorized-antenna choice only matters once more than one rotor exists.
|
||||||
|
const multi = rotors.length > 1 || rotors.some((d) => (d as any).type === 'rotgenius' && (d as any).dual);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader title="Rotator" hint={anyPst ? t('rot.hint') : undefined} />
|
||||||
title="Rotator"
|
|
||||||
hint={isRG || isARCO ? undefined : t('rot.hint')}
|
|
||||||
/>
|
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
{rotors.length === 0 && <p className="text-sm text-muted-foreground">{t('rot.none')}</p>}
|
||||||
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
|
{rotors.map((d, i) => {
|
||||||
{t('rot.enable')}
|
const dev = d as any;
|
||||||
</label>
|
const isRG = dev.type === 'rotgenius';
|
||||||
|
const isARCO = dev.type === 'arco';
|
||||||
|
const transport = dev.transport ?? 'tcp';
|
||||||
|
return (
|
||||||
|
<div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Compass className="size-4 text-primary shrink-0" />
|
||||||
|
<Input className="h-8 flex-1" value={dev.name ?? ''} placeholder={`Rotor ${i + 1}`}
|
||||||
|
onChange={(e) => patch(i, { name: e.target.value })} />
|
||||||
|
<Button size="icon" variant="ghost" className="size-8 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => removeRotor(i)} title={t('rot.remove')}>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('rot.type')}</Label>
|
<Label>{t('rot.type')}</Label>
|
||||||
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
|
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
|
||||||
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
|
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
|
||||||
<Select value={(rotator as any).type ?? 'pst'}
|
<Select value={dev.type ?? 'pst'}
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 } as any))}>
|
onValueChange={(v) => patch(i, { type: v as any, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 })}>
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||||||
@@ -3333,11 +3415,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{isRG && (
|
{isRG && !dev.dual && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('rot.rotatorNum')}</Label>
|
<Label>{t('rot.rotatorNum')}</Label>
|
||||||
<Select value={String((rotator as any).rotator_num ?? 1)}
|
<Select value={String(dev.rotator_num ?? 1)}
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, rotator_num: parseInt(v, 10) || 1 } as any))}>
|
onValueChange={(v) => patch(i, { rotator_num: parseInt(v, 10) || 1 })}>
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="1">1</SelectItem>
|
<SelectItem value="1">1</SelectItem>
|
||||||
@@ -3350,8 +3432,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
{isARCO && (
|
{isARCO && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Connection</Label>
|
<Label>Connection</Label>
|
||||||
<Select value={(rotator as any).transport ?? 'tcp'}
|
<Select value={transport} onValueChange={(v) => patch(i, { transport: v as any })}>
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, transport: v } as any))}>
|
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="tcp">Network (LAN)</SelectItem>
|
<SelectItem value="tcp">Network (LAN)</SelectItem>
|
||||||
@@ -3361,12 +3442,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isARCO && (rotator as any).transport === 'serial' ? (
|
{/* Rotator Genius drives both its ports — one entry = two rotors. */}
|
||||||
|
{isRG && (
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!dev.dual} onCheckedChange={(c) => patch(i, { dual: !!c })} />
|
||||||
|
{t('rot.rgDual')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{isARCO && transport === 'serial' ? (
|
||||||
<div className="space-y-1 max-w-xs">
|
<div className="space-y-1 max-w-xs">
|
||||||
<Label>COM port</Label>
|
<Label>COM port</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={(rotator as any).com_port || '_'}
|
<Select value={dev.com_port || '_'} onValueChange={(v) => patch(i, { com_port: v === '_' ? '' : v })}>
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, com_port: v === '_' ? '' : v } as any))}>
|
|
||||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||||
@@ -3376,8 +3463,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
<ArrowDown className="size-3.5 rotate-90" />
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
</Button>
|
</Button>
|
||||||
<Select value={String((rotator as any).baud || 9600)}
|
<Select value={String(dev.baud || 9600)} onValueChange={(v) => patch(i, { baud: Number(v) })}>
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, baud: Number(v) } as any))}>
|
|
||||||
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{[4800, 9600, 19200, 38400, 57600].map((b) => (
|
{[4800, 9600, 19200, 38400, 57600].map((b) => (
|
||||||
@@ -3391,44 +3477,85 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label>Host / IP</Label>
|
<Label>Host / IP</Label>
|
||||||
<Input
|
<Input value={dev.host ?? ''} onChange={(e) => patch(i, { host: e.target.value })}
|
||||||
value={rotator.host ?? ''}
|
placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'} className="font-mono" />
|
||||||
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
|
|
||||||
placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'}
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
|
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
|
||||||
<PortInput
|
<PortInput value={dev.port} onChange={(n) => patch(i, { port: n })}
|
||||||
value={rotator.port}
|
fallback={isRG ? 9006 : isARCO ? 4001 : 12000} className="font-mono" />
|
||||||
onChange={(n) => setRotator((s) => ({ ...s, port: n }))} fallback={isRG ? 9006 : isARCO ? 4001 : 12000}
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isRG && !isARCO && (
|
{!isRG && !isARCO && (
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
<Checkbox checked={!!dev.has_elevation} onCheckedChange={(c) => patch(i, { has_elevation: !!c })} />
|
||||||
This rotator supports elevation (VHF / satellite)
|
This rotator supports elevation (VHF / satellite)
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||||||
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
||||||
<div className="flex items-center gap-2 pt-2">
|
{/* Which antenna this rotor carries — only relevant with >1 rotor. */}
|
||||||
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
{multi && (
|
||||||
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
<div className="space-y-1 max-w-xs">
|
||||||
|
<Label>{isRG && dev.dual ? t('rot.antenna1') : t('rot.antenna')}</Label>
|
||||||
|
<Select value={dev.motorized ? 'motor' : 'std'} onValueChange={(v) => patch(i, { motorized: v === 'motor' })}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="std">{t('rot.antStd')}</SelectItem>
|
||||||
|
<SelectItem value="motor">{t('rot.antMotor')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Second logical rotor of a dual Rotator Genius: its own name + antenna. */}
|
||||||
|
{isRG && dev.dual && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('rot.name2')}</Label>
|
||||||
|
<Input value={dev.name2 ?? ''} placeholder={`${dev.name || 'Rotor'} 2`}
|
||||||
|
onChange={(e) => patch(i, { name2: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('rot.antenna2')}</Label>
|
||||||
|
<Select value={dev.motorized2 ? 'motor' : 'std'} onValueChange={(v) => patch(i, { motorized2: v === 'motor' })}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="std">{t('rot.antStd')}</SelectItem>
|
||||||
|
<SelectItem value="motor">{t('rot.antMotor')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => testRotator(d, 1)} disabled={rotatorTesting}>
|
||||||
|
{rotatorTesting ? t('hw.sending') : (isRG && dev.dual ? t('rot.testRotor1') : t('hw.testRotator'))}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
{isRG && dev.dual && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => testRotator(d, 2)} disabled={rotatorTesting}>
|
||||||
|
{t('rot.testRotor2')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button variant="outline" size="sm" onClick={addRotor}>
|
||||||
|
<Plus className="size-3.5 mr-1" /> {t('rot.add')}
|
||||||
|
</Button>
|
||||||
|
{rotors.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))}>
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
{!isRG && (
|
{anyPst && (
|
||||||
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))}>
|
||||||
Park
|
Park
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{rotatorTest && (
|
{rotatorTest && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'text-xs rounded-md p-2.5 border',
|
'text-xs rounded-md p-2.5 border',
|
||||||
@@ -3440,8 +3567,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
From the main entry strip, click the bearing pill to rotate to the short-path azimuth.
|
From the main entry strip, click the bearing pill to rotate the active rotor to the short-path
|
||||||
Shift+click for long-path, Ctrl+click to stop.
|
azimuth. Shift+click for long-path, Ctrl+click to stop. With several rotors, pick the active one
|
||||||
|
on the compass widget.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -3479,6 +3607,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<SelectItem value="serial">{t('wk.engSerial')}</SelectItem>
|
<SelectItem value="serial">{t('wk.engSerial')}</SelectItem>
|
||||||
<SelectItem value="icom">{t('wk.engIcom')}</SelectItem>
|
<SelectItem value="icom">{t('wk.engIcom')}</SelectItem>
|
||||||
<SelectItem value="yaesu">{t('wk.engYaesu')}</SelectItem>
|
<SelectItem value="yaesu">{t('wk.engYaesu')}</SelectItem>
|
||||||
|
<SelectItem value="kenwood">{t('wk.engKenwood')}</SelectItem>
|
||||||
<SelectItem value="flex">{t('wk.engFlex')}</SelectItem>
|
<SelectItem value="flex">{t('wk.engFlex')}</SelectItem>
|
||||||
<SelectItem value="tci" disabled>{t('wk.engTci')}</SelectItem>
|
<SelectItem value="tci" disabled>{t('wk.engTci')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -3525,6 +3654,22 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : wk.engine === 'kenwood' ? (
|
||||||
|
<>
|
||||||
|
{(!catCfg.enabled || catCfg.backend !== 'kenwood') && (
|
||||||
|
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||||||
|
<span aria-hidden>⚠</span>
|
||||||
|
<span>{t('wk.catWarnKenwood', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground -mt-1">{t('wk.kenwoodHint')}</p>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('wk.speed')}</Label>
|
||||||
|
<Input type="number" min={4} max={60} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : wk.engine === 'flex' ? (
|
) : wk.engine === 'flex' ? (
|
||||||
<>
|
<>
|
||||||
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
||||||
@@ -3856,6 +4001,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<span className="font-mono">dx.maritimecontestclub.net:7300</span>,{' '}
|
<span className="font-mono">dx.maritimecontestclub.net:7300</span>,{' '}
|
||||||
<span className="font-mono">w8avi.net:7300</span>.
|
<span className="font-mono">w8avi.net:7300</span>.
|
||||||
</p>
|
</p>
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
|
||||||
|
<Checkbox checked={clusterWorkedSameSlot} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setClusterWorkedSameSlot(v); writeUiPref('opslog.clusterWorkedSameSlot', v ? '1' : '0'); }} />
|
||||||
|
<span>{t('clu.workedSameSlot')} <span className="text-xs text-muted-foreground">{t('clu.workedSameSlotHint')}</span></span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editingServer && (
|
{editingServer && (
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { TunerCard } from '@/components/TunerCard';
|
|||||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||||
import {
|
import {
|
||||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
GetRotatorHeading, RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||||
GetAmpStatuses, GetFlexState,
|
GetAmpStatuses, GetFlexState,
|
||||||
@@ -47,7 +47,7 @@ function blankDevice(): Device {
|
|||||||
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||||
}
|
}
|
||||||
|
|
||||||
type Heading = { enabled: boolean; ok: boolean; azimuth: number };
|
type Heading = { enabled: boolean; ok: boolean; azimuth: number; rotors?: string[]; active?: number };
|
||||||
|
|
||||||
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
||||||
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
||||||
@@ -82,6 +82,9 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
|||||||
centerLat={centerLat ?? null}
|
centerLat={centerLat ?? null}
|
||||||
centerLon={centerLon ?? null}
|
centerLon={centerLon ?? null}
|
||||||
rotorEnabled={hd.ok}
|
rotorEnabled={hd.ok}
|
||||||
|
rotors={hd.rotors}
|
||||||
|
activeRotor={hd.active}
|
||||||
|
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
||||||
onGoto={(az) => turn(az)}
|
onGoto={(az) => turn(az)}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0 space-y-2">
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface Props {
|
|||||||
wpm: number;
|
wpm: number;
|
||||||
macros: WKMacro[];
|
macros: WKMacro[];
|
||||||
sent: string; // text echoed back by the keyer as it transmits
|
sent: string; // text echoed back by the keyer as it transmits
|
||||||
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu'; // CW output engine (chosen in Settings → CW Keyer)
|
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood'; // CW output engine (chosen in Settings → CW Keyer)
|
||||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||||
onSetBreakIn?: (mode: number) => void;
|
onSetBreakIn?: (mode: number) => void;
|
||||||
onSelectPort: (p: string) => void;
|
onSelectPort: (p: string) => void;
|
||||||
@@ -101,16 +101,16 @@ export function WinkeyerPanel({
|
|||||||
<Radio className="size-4 text-primary shrink-0" />
|
<Radio className="size-4 text-primary shrink-0" />
|
||||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||||
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : 'WinKeyer'}
|
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : source === 'kenwood' ? 'Kenwood CW' : 'WinKeyer'}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{source === 'icom' || source === 'flex' || source === 'yaesu' ? (
|
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' ? (
|
||||||
<span className="text-[11px] font-medium text-muted-foreground">
|
<span className="text-[11px] font-medium text-muted-foreground">
|
||||||
{source === 'flex'
|
{source === 'flex'
|
||||||
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||||
: source === 'yaesu'
|
: source === 'yaesu' || source === 'kenwood'
|
||||||
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
||||||
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+14
-10
@@ -163,7 +163,7 @@ const en: Dict = {
|
|||||||
'wk.escClears': 'ESC clears the callsign too (otherwise ESC only stops transmission)',
|
'wk.escClears': 'ESC clears the callsign too (otherwise ESC only stops transmission)',
|
||||||
'wk.esm': 'ESM — Enter Sends Message (CW)',
|
'wk.esm': 'ESM — Enter Sends Message (CW)',
|
||||||
'wk.esmHint': 'In CW with the keyer on, Enter fires a macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign entered → F2 (report) and focus jumps to RST; Enter in RST → F3 (TU), which logs if the macro contains <LOGQSO>.',
|
'wk.esmHint': 'In CW with the keyer on, Enter fires a macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign entered → F2 (report) and focus jumps to RST; Enter in RST → F3 (TU), which logs if the macro contains <LOGQSO>.',
|
||||||
'wk.engWinkeyer': 'WinKeyer (K1EL, serial)', 'wk.engSerial': 'Serial port (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (rig keyer)', 'wk.engYaesu': 'Yaesu (rig keyer)', 'wk.yaesuHint': 'Keying goes over the CAT link already configured in Settings → CAT — no second COM port. For the FTDX101 / FT-991A / FT-710 family. An FTDX10 does NOT accept it (DAKY included): on that one use the "Serial port (DTR=CW / RTS=PTT)" keyer on its other COM port (the standard one) with PC KEYING on DTR — confirmed working.', 'wk.catWarnYaesu': 'The Yaesu keyer needs the Yaesu CAT backend — CAT is currently {backend}.', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (coming soon)',
|
'wk.engWinkeyer': 'WinKeyer (K1EL, serial)', 'wk.engSerial': 'Serial port (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (rig keyer)', 'wk.engYaesu': 'Yaesu (rig keyer)', 'wk.yaesuHint': 'Keying goes over the CAT link already configured in Settings → CAT — no second COM port. For the FTDX101 / FT-991A / FT-710 family. An FTDX10 does NOT accept it (DAKY included): on that one use the "Serial port (DTR=CW / RTS=PTT)" keyer on its other COM port (the standard one) with PC KEYING on DTR — confirmed working.', 'wk.catWarnYaesu': 'The Yaesu keyer needs the Yaesu CAT backend — CAT is currently {backend}.', 'wk.engKenwood': 'Kenwood / Elecraft (rig keyer)', 'wk.kenwoodHint': 'Keying goes over the CAT link already configured in Settings → CAT (the KY command) — no second COM port. Ideal for an Elecraft K3/K4 whose single USB port is the CAT port.', 'wk.catWarnKenwood': 'The Kenwood/Elecraft keyer needs the Kenwood/Elecraft CAT backend — CAT is currently {backend}.', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (coming soon)',
|
||||||
'wk.icomNote': "Icom CI-V keys CW through the rig's own keyer over the existing CAT connection (command 0x17) — it reuses the CAT COM port set in Settings → CAT, so there's nothing else to wire up here. Put the rig in CW mode. Weight, ratio, sidetone, paddle mode… are configured on the radio; only the speed is set from here (the rig's KEY SPEED).",
|
'wk.icomNote': "Icom CI-V keys CW through the rig's own keyer over the existing CAT connection (command 0x17) — it reuses the CAT COM port set in Settings → CAT, so there's nothing else to wire up here. Put the rig in CW mode. Weight, ratio, sidetone, paddle mode… are configured on the radio; only the speed is set from here (the rig's KEY SPEED).",
|
||||||
'wk.flexNote': "FlexRadio keys CW through the radio's CWX keyer over the existing SmartSDR CAT connection — no WinKeyer or SmartCAT needed. It reuses the connection set in Settings → CAT, so there's nothing else to wire up here. Put a slice in CW mode. Only the speed is set from here; weight, sidetone and break-in are configured on the radio (break-in must be on for CW to actually transmit).",
|
'wk.flexNote': "FlexRadio keys CW through the radio's CWX keyer over the existing SmartSDR CAT connection — no WinKeyer or SmartCAT needed. It reuses the connection set in Settings → CAT, so there's nothing else to wire up here. Put a slice in CW mode. Only the speed is set from here; weight, sidetone and break-in are configured on the radio (break-in must be on for CW to actually transmit).",
|
||||||
'wk.catWarnIcom': 'Your CAT backend is set to {backend}. Icom CI-V CW needs the CAT backend set to Icom and connected — change it under Settings → CAT interface, otherwise sending CW will fail.',
|
'wk.catWarnIcom': 'Your CAT backend is set to {backend}. Icom CI-V CW needs the CAT backend set to Icom and connected — change it under Settings → CAT interface, otherwise sending CW will fail.',
|
||||||
@@ -188,7 +188,7 @@ const en: Dict = {
|
|||||||
'gen.showBeam': 'Show the antenna beam heading on the Main map',
|
'gen.showBeam': 'Show the antenna beam heading on the Main map',
|
||||||
'gen.startEqEnd': 'QSO start time = end time', 'gen.startEqEndHint': '(matches LoTW when you call a while)',
|
'gen.startEqEnd': 'QSO start time = end time', 'gen.startEqEndHint': '(matches LoTW when you call a while)',
|
||||||
'gen.showQsoRate': 'Show QSO rate in the header', 'gen.showQsoRateHint': '(QSOs/hour, projected from the last 10 / 60 min)',
|
'gen.showQsoRate': 'Show QSO rate in the header', 'gen.showQsoRateHint': '(QSOs/hour, projected from the last 10 / 60 min)',
|
||||||
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)', 'amp.hint': 'Configure one or several amplifiers — each panel card has a dropdown to pick which one it shows.', 'amp.none': 'No amplifier configured yet.', 'amp.namePh': 'Name (e.g. SPE left)', 'amp.remove': 'Remove this amplifier', 'amp.add': 'Add amplifier', 'amp.freqOut': 'Send the frequency to the amplifier (band follow)', 'amp.freqPort': 'CAT/AUX COM port', 'amp.freqBroadcast': 'Also send unprompted', 'amp.freqPollOnly': 'No — answer the amplifier only', 'amp.freqEvery': 'Yes, every {ms} ms', 'amp.freqHint': 'OpsLog pretends to be a transceiver on this second port, in Kenwood format: set the amplifier to that command set (set 5 on an ACOM) at the same baud rate, and put it in OPERATE — in standby it acknowledges but does not switch band. An amplifier that POLLS (ACOM) needs nothing more; one that only LISTENS to the CAT line of the radio hears nothing unless you also turn on the unprompted send.',
|
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)', 'amp.hint': 'Configure one or several amplifiers — each panel card has a dropdown to pick which one it shows.', 'amp.none': 'No amplifier configured yet.', 'amp.namePh': 'Name (e.g. SPE left)', 'amp.remove': 'Remove this amplifier', 'amp.add': 'Add amplifier', 'amp.password': 'Remote code', 'amp.passwordPh': 'blank on LAN', 'amp.passwordHint': 'PowerGenius XL only: needed when reaching the amp remotely — it then announces "AUTH" and rejects every command ("Unauthorized") until you log in. Leave blank on the local network.', 'amp.freqOut': 'Send the frequency to the amplifier (band follow)', 'amp.freqPort': 'CAT/AUX COM port', 'amp.freqBroadcast': 'Also send unprompted', 'amp.freqPollOnly': 'No — answer the amplifier only', 'amp.freqEvery': 'Yes, every {ms} ms', 'amp.freqHint': 'OpsLog pretends to be a transceiver on this second port, in Kenwood format: set the amplifier to that command set (set 5 on an ACOM) at the same baud rate, and put it in OPERATE — in standby it acknowledges but does not switch band. An amplifier that POLLS (ACOM) needs nothing more; one that only LISTENS to the CAT line of the radio hears nothing unless you also turn on the unprompted send.',
|
||||||
'gen.groupDigital': 'Group digital modes as one (DXCC-style)', 'gen.groupDigitalHint': '(matrix badges + cluster: FT8/FT4/RTTY… count as a single Digital mode; off = each digital mode is its own slot)',
|
'gen.groupDigital': 'Group digital modes as one (DXCC-style)', 'gen.groupDigitalHint': '(matrix badges + cluster: FT8/FT4/RTTY… count as a single Digital mode; off = each digital mode is its own slot)',
|
||||||
// Password encryption
|
// Password encryption
|
||||||
'gen.pwEnc': 'Password encryption',
|
'gen.pwEnc': 'Password encryption',
|
||||||
@@ -262,6 +262,8 @@ const en: Dict = {
|
|||||||
'clu.moveUp': 'Move up', 'clu.moveDown': 'Move down', 'clu.edit': 'Edit', 'clu.delete': 'Delete', 'clu.none': 'No cluster nodes saved yet.', 'clu.connect': 'Connect', 'clu.disconnect': 'Disconnect',
|
'clu.moveUp': 'Move up', 'clu.moveDown': 'Move down', 'clu.edit': 'Edit', 'clu.delete': 'Delete', 'clu.none': 'No cluster nodes saved yet.', 'clu.connect': 'Connect', 'clu.disconnect': 'Disconnect',
|
||||||
'clu.add': 'Add cluster', 'clu.connectAll': 'Connect all', 'clu.disconnectAll': 'Disconnect all', 'clu.autoConnect': 'Auto-connect all enabled on app start',
|
'clu.add': 'Add cluster', 'clu.connectAll': 'Connect all', 'clu.disconnectAll': 'Disconnect all', 'clu.autoConnect': 'Auto-connect all enabled on app start',
|
||||||
'clu.freeNodes': 'Free public nodes:',
|
'clu.freeNodes': 'Free public nodes:',
|
||||||
|
'clu.workedSameSlot': 'Already worked only on the same slot',
|
||||||
|
'clu.workedSameSlotHint': '— a spot shows "worked" only if you worked that call on the SAME band and mode, not just anywhere. Combines with digital-mode grouping (Settings → General): with it on, a call worked on 20m FT8 also counts as worked for a 20m FT4 spot; with it off, FT8 and FT4 are separate slots.',
|
||||||
// Backup panel
|
// Backup panel
|
||||||
'bk.hintMysql': 'On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each.',
|
'bk.hintMysql': 'On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each.',
|
||||||
'bk.hint': 'OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones.',
|
'bk.hint': 'OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones.',
|
||||||
@@ -273,11 +275,11 @@ const en: Dict = {
|
|||||||
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
|
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
|
||||||
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||||
'tg2.hint': 'OpsLog talks to the 4O3A Tuner Genius XL over TCP (port fixed at 9010), so only the device IP is needed. A docked widget then shows SWR and forward power and offers Tune, Bypass and Operate/Standby. Control it directly (not through the radio) so OpsLog uses just one of the box\'s connection slots.', 'tg2.enable': 'Enable Tuner Genius control', 'tg2.portHint': 'The TCP port is fixed at 9010 on the device.', 'tg2.password': 'Remote code', 'tg2.passwordPh': 'blank on LAN', 'tg2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
'tg2.hint': 'OpsLog talks to the 4O3A Tuner Genius XL over TCP (port fixed at 9010), so only the device IP is needed. A docked widget then shows SWR and forward power and offers Tune, Bypass and Operate/Standby. Control it directly (not through the radio) so OpsLog uses just one of the box\'s connection slots.', 'tg2.enable': 'Enable Tuner Genius control', 'tg2.portHint': 'The TCP port is fixed at 9010 on the device.', 'tg2.password': 'Remote code', 'tg2.passwordPh': 'blank on LAN', 'tg2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||||
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to any controller set to Yaesu GS-232A — no PstRotator needed. microHAM ARCO: set Config → LAN → CONTROL PROTOCOL (or USB CONTROL PROTOCOL) to 'Yaesu GS-232A'; on USB the baud rate does not matter. ERC (Easy Rotor Control, incl. ERC Mini): its emulation MUST be set to GS-232 — an ERC left on Hy-Gain DCU-1 speaks a different command set and will not answer — then pick its COM port and the same baud rate as in the ERC configuration.", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.dual': 'Two rotors', 'rot.rotor1': 'Rotor 1', 'rot.rotor2': 'Rotor 2', 'rot.name': 'Name', 'rot.antenna': 'Antenna', 'rot.antenna1': 'Rotor 1 antenna', 'rot.antenna2': 'Rotor 2 antenna', 'rot.name1': 'Rotor 1 name', 'rot.name2': 'Rotor 2 name', 'rot.antStd': 'Standard antenna', 'rot.antMotor': 'Motorized (Ultrabeam/SteppIR)', 'rot.add': 'Add rotor', 'rot.remove': 'Remove rotor', 'rot.none': 'No rotor configured. Click “Add rotor” to add one.', 'rot.rgDual': 'This Rotator Genius drives two rotors (adds a second one)', 'rot.testRotor1': 'Test rotor 1', 'rot.testRotor2': 'Test rotor 2', 'rot.dualHint': 'Two independent rotors of any type — for example an ARCO and a Rotator Genius side by side. Configure each below; OpsLog shows a Rotor 1/2 toggle on the compass to pick which one it turns and displays. Set each rotor to "Motorized" so the boom/pattern paths (reverse, bidirectional) show only for the one that carries the Ultrabeam/SteppIR. (For a single Rotator Genius driving two rotors, set both to Rotator Genius with the same host and rotator # 1 and 2.)', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to any controller set to Yaesu GS-232A — no PstRotator needed. microHAM ARCO: set Config → LAN → CONTROL PROTOCOL (or USB CONTROL PROTOCOL) to 'Yaesu GS-232A'; on USB the baud rate does not matter. ERC (Easy Rotor Control, incl. ERC Mini): its emulation MUST be set to GS-232 — an ERC left on Hy-Gain DCU-1 speaks a different command set and will not answer — then pick its COM port and the same baud rate as in the ERC configuration.", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
||||||
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
||||||
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||||
// CAT panel body
|
// CAT panel body
|
||||||
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.xieguPTTLine': 'How the rig is keyed', 'cat.xieguPTTCiv': 'CI-V command', 'cat.xieguPTTHint': 'A G90 does not transmit on the CI-V command: interfaces like the DE-19 key it on RTS or DTR. Pick the line yours uses \u2014 it is also what lets WSJT-X transmit through the shared CAT link.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.lowerLines': 'Lower the DTR and RTS lines on connect', 'cat.lowerLinesHint': 'Windows raises both when the port opens, and some interfaces read either line as PTT — the radio then transmits for as long as OpsLog runs. Tick this only if that happens: other interfaces need RTS raised to transmit at all, and lowering it makes the rig go silent.', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 power per band and mode', 'flxpw.hint': 'TX power applied when the band or mode changes. Leave a box empty to leave the power alone.', 'flxpw.band': 'Band', 'flxb.title': 'FlexRadio \u2014 per-band antennas and power', 'flxb.hint': 'Antennas are applied when the band changes; power when the band or the mode changes. Leave a power box empty to leave it alone.', 'flxb.rxAnt': 'RX antenna', 'flxb.txAnt': 'TX antenna', 'flxb.noRadio': 'No antennas reported yet \u2014 connect the FlexRadio, then reopen this panel.', 'flxpw.phone': 'Phone', 'flxpw.digi': 'Digital', 'flxpw.noBands': 'No bands configured \u2014 add them in Lists \u2192 Bands.', 'flxpw.saved': 'Saved',
|
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'In the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.pttKey': 'Enable PTT hotkey', 'cat.pttKeyPress': 'Press a key…', 'cat.pttKeyNone': 'Click to set a key', 'cat.pttKeyClear': 'Clear', 'cat.pttKeyToggle': 'Toggle mode (press to key, press again to unkey)', 'cat.pttKeyHint': 'While OpsLog is focused, this key keys the transmitter — held down by default (release to stop), or latched in toggle mode. It uses the Audio → PTT method (CAT / RTS / DTR), falling back to CAT keying. Pick a key you never type while logging (e.g. Pause, ScrollLock, or a footswitch mapped to one) — OpsLog swallows it so it never lands in a field.', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.xieguPTTLine': 'How the rig is keyed', 'cat.xieguPTTCiv': 'CI-V command', 'cat.xieguPTTHint': 'A G90 does not transmit on the CI-V command: interfaces like the DE-19 key it on RTS or DTR. Pick the line yours uses \u2014 it is also what lets WSJT-X transmit through the shared CAT link.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.lowerLines': 'Lower the DTR and RTS lines on connect', 'cat.lowerLinesHint': 'If your radio is always on TX, tick this.', 'cat.kwDataMode': 'Data modes (FT8/PSK…) use', 'cat.kwDataUsb': 'USB', 'cat.kwDataMd6': 'DATA mode — MD6 (Elecraft K3/K4)', 'cat.kwDataKeep': 'Leave the rig’s mode unchanged', 'cat.kwDataHint': 'What OpsLog sets on the rig for a data mode. No single command fits every rig: an Elecraft K3/K4 wants DATA (MD6); a TS-590SG/TS-990S data mode is a USB modifier set on the rig, so pick USB or, safest, "Leave unchanged" and switch the rig to DATA yourself. On MD6 a plain Kenwood (TS-590/990) would land on FSK/RTTY — do not use it there.', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 power per band and mode', 'flxpw.hint': 'TX power applied when the band or mode changes. Leave a box empty to leave the power alone.', 'flxpw.band': 'Band', 'flxb.title': 'FlexRadio \u2014 per-band antennas and power', 'flxb.hint': 'Antennas are applied when the band changes; power when the band or the mode changes. Leave a power box empty to leave it alone.', 'flxb.rxAnt': 'RX antenna', 'flxb.txAnt': 'TX antenna', 'flxb.noRadio': 'No antennas reported yet \u2014 connect the FlexRadio, then reopen this panel.', 'flxpw.phone': 'Phone', 'flxpw.digi': 'Digital', 'flxpw.noBands': 'No bands configured \u2014 add them in Lists \u2192 Bands.', 'flxpw.saved': 'Saved',
|
||||||
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
||||||
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
||||||
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
||||||
@@ -376,7 +378,7 @@ const en: Dict = {
|
|||||||
// Awards (ref picker / ref selector / awards panel / award editor)
|
// Awards (ref picker / ref selector / awards panel / award editor)
|
||||||
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
|
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
|
||||||
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
|
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
|
||||||
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.modePhone': 'Phone', 'awp.modeDigital': 'Digital', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.modePhone': 'Phone', 'awp.modeDigital': 'Digital', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.prefixCol': 'Prefix', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
||||||
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.oneRef': 'One reference per QSO', 'awed.oneRefHint': 'When several references match the same contact, assign none and list it under missing references. For awards where two entries can share a description — two DOKs called Gießen — picking one at random would write the wrong one into the log.', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
|
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.oneRef': 'One reference per QSO', 'awed.oneRefHint': 'When several references match the same contact, assign none and list it under missing references. For awards where two entries can share a description — two DOKs called Gießen — picking one at random would write the wrong one into the log.', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
|
||||||
'awed.updateAvailable': 'An updated version of this award is available', 'awed.updateOverwrites': 'You have modified this award, so the update was not applied. Taking it replaces your definition and reference list.', 'awed.updateApply': 'Update', 'awed.updateKeepMine': 'Keep mine',
|
'awed.updateAvailable': 'An updated version of this award is available', 'awed.updateOverwrites': 'You have modified this award, so the update was not applied. Taking it replaces your definition and reference list.', 'awed.updateApply': 'Update', 'awed.updateKeepMine': 'Keep mine',
|
||||||
'awed.tabTest': 'Test', 'awed.testCallsign': 'Test against callsign', 'awed.testRun': 'Test', 'awed.testSavedOnly': 'Tests the SAVED award — save your changes first.', 'awed.testNoMatch': 'no match', 'awed.testOutOfScope': 'QSO out of scope — no rule was run.', 'awed.testSkipped': 'not run: an earlier rule already matched', 'awed.testFieldValue': 'Field', 'awed.testEmptyField': 'empty', 'awed.testNoCandidate': 'produced no candidate', 'awed.testManual': 'Manual override', 'awed.testAmbiguous': 'Ambiguous', 'awed.testAmbiguousHint': '— none kept, this award allows one reference per QSO. Assign the right one by hand.', 'awed.testSameAs': '+{n} other QSO(s), same result',
|
'awed.tabTest': 'Test', 'awed.testCallsign': 'Test against callsign', 'awed.testRun': 'Test', 'awed.testSavedOnly': 'Tests the SAVED award — save your changes first.', 'awed.testNoMatch': 'no match', 'awed.testOutOfScope': 'QSO out of scope — no rule was run.', 'awed.testSkipped': 'not run: an earlier rule already matched', 'awed.testFieldValue': 'Field', 'awed.testEmptyField': 'empty', 'awed.testNoCandidate': 'produced no candidate', 'awed.testManual': 'Manual override', 'awed.testAmbiguous': 'Ambiguous', 'awed.testAmbiguousHint': '— none kept, this award allows one reference per QSO. Assign the right one by hand.', 'awed.testSameAs': '+{n} other QSO(s), same result',
|
||||||
@@ -580,7 +582,7 @@ const fr: Dict = {
|
|||||||
'wk.escClears': "ÉCHAP efface aussi l'indicatif (sinon ÉCHAP arrête seulement la transmission)",
|
'wk.escClears': "ÉCHAP efface aussi l'indicatif (sinon ÉCHAP arrête seulement la transmission)",
|
||||||
'wk.esm': 'ESM — Entrée envoie le message (CW)',
|
'wk.esm': 'ESM — Entrée envoie le message (CW)',
|
||||||
'wk.esmHint': "En CW avec le keyer actif, Entrée envoie un macro selon l'étape du QSO au lieu de loguer : indicatif vide → F1 (CQ) ; indicatif saisi → F2 (report) et le focus passe au RST ; Entrée dans le RST → F3 (TU), qui logue si le macro contient <LOGQSO>.",
|
'wk.esmHint': "En CW avec le keyer actif, Entrée envoie un macro selon l'étape du QSO au lieu de loguer : indicatif vide → F1 (CQ) ; indicatif saisi → F2 (report) et le focus passe au RST ; Entrée dans le RST → F3 (TU), qui logue si le macro contient <LOGQSO>.",
|
||||||
'wk.engWinkeyer': 'WinKeyer (K1EL, série)', 'wk.engSerial': 'Port série (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (keyer de la radio)', 'wk.engYaesu': 'Yaesu (keyer de la radio)', 'wk.yaesuHint': "La manipulation passe par la liaison CAT déjà configurée dans Réglages → CAT — sans second port COM. Pour la famille FTDX101 / FT-991A / FT-710. Un FTDX10 ne l'accepte PAS (DAKY compris) : sur celui-ci, utilisez le keyer « Port série (DTR=CW / RTS=PTT) » sur son autre port COM (le standard) avec PC KEYING sur DTR — confirmé fonctionnel.", 'wk.catWarnYaesu': 'Le keyer Yaesu nécessite le backend CAT Yaesu — le CAT est actuellement : {backend}.', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (bientôt)',
|
'wk.engWinkeyer': 'WinKeyer (K1EL, série)', 'wk.engSerial': 'Port série (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (keyer de la radio)', 'wk.engYaesu': 'Yaesu (keyer de la radio)', 'wk.yaesuHint': "La manipulation passe par la liaison CAT déjà configurée dans Réglages → CAT — sans second port COM. Pour la famille FTDX101 / FT-991A / FT-710. Un FTDX10 ne l'accepte PAS (DAKY compris) : sur celui-ci, utilisez le keyer « Port série (DTR=CW / RTS=PTT) » sur son autre port COM (le standard) avec PC KEYING sur DTR — confirmé fonctionnel.", 'wk.catWarnYaesu': 'Le keyer Yaesu nécessite le backend CAT Yaesu — le CAT est actuellement : {backend}.', 'wk.engKenwood': 'Kenwood / Elecraft (keyer de la radio)', 'wk.kenwoodHint': "La manipulation passe par la liaison CAT déjà configurée dans Réglages → CAT (la commande KY) — sans second port COM. Idéal pour un Elecraft K3/K4 dont l'unique port USB est le port CAT.", 'wk.catWarnKenwood': 'Le keyer Kenwood/Elecraft nécessite le backend CAT Kenwood/Elecraft — le CAT est actuellement : {backend}.', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (bientôt)',
|
||||||
'wk.icomNote': "L'Icom CI-V manipule la CW via le keyer interne de la radio sur la connexion CAT existante (commande 0x17) — il réutilise le port COM CAT défini dans Réglages → CAT, rien d'autre à câbler ici. Mets la radio en mode CW. Poids, ratio, sidetone, mode paddle… se règlent sur la radio ; seule la vitesse est définie ici (KEY SPEED de la radio).",
|
'wk.icomNote': "L'Icom CI-V manipule la CW via le keyer interne de la radio sur la connexion CAT existante (commande 0x17) — il réutilise le port COM CAT défini dans Réglages → CAT, rien d'autre à câbler ici. Mets la radio en mode CW. Poids, ratio, sidetone, mode paddle… se règlent sur la radio ; seule la vitesse est définie ici (KEY SPEED de la radio).",
|
||||||
'wk.flexNote': "FlexRadio manipule la CW via le keyer CWX de la radio sur la connexion SmartSDR CAT existante — pas besoin de WinKeyer ni de SmartCAT. Il réutilise la connexion définie dans Réglages → CAT, rien d'autre à câbler ici. Mets une slice en mode CW. Seule la vitesse est définie ici ; poids, sidetone et break-in se règlent sur la radio (le break-in doit être activé pour que la CW parte vraiment).",
|
'wk.flexNote': "FlexRadio manipule la CW via le keyer CWX de la radio sur la connexion SmartSDR CAT existante — pas besoin de WinKeyer ni de SmartCAT. Il réutilise la connexion définie dans Réglages → CAT, rien d'autre à câbler ici. Mets une slice en mode CW. Seule la vitesse est définie ici ; poids, sidetone et break-in se règlent sur la radio (le break-in doit être activé pour que la CW parte vraiment).",
|
||||||
'wk.catWarnIcom': "Ton backend CAT est réglé sur {backend}. La CW Icom CI-V nécessite le backend CAT réglé sur Icom et connecté — change-le dans Réglages → Interface CAT, sinon l'envoi CW échouera.",
|
'wk.catWarnIcom': "Ton backend CAT est réglé sur {backend}. La CW Icom CI-V nécessite le backend CAT réglé sur Icom et connecté — change-le dans Réglages → Interface CAT, sinon l'envoi CW échouera.",
|
||||||
@@ -604,7 +606,7 @@ const fr: Dict = {
|
|||||||
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
||||||
'gen.startEqEnd': 'Heure de début du QSO = heure de fin', 'gen.startEqEndHint': '(correspond à LoTW quand tu appelles un moment)',
|
'gen.startEqEnd': 'Heure de début du QSO = heure de fin', 'gen.startEqEndHint': '(correspond à LoTW quand tu appelles un moment)',
|
||||||
'gen.showQsoRate': 'Afficher le rythme QSO dans la barre du haut', 'gen.showQsoRateHint': '(QSO/heure, projeté sur les 10 / 60 dernières min)',
|
'gen.showQsoRate': 'Afficher le rythme QSO dans la barre du haut', 'gen.showQsoRateHint': '(QSO/heure, projeté sur les 10 / 60 dernières min)',
|
||||||
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)', 'amp.hint': 'Configurez un ou plusieurs amplificateurs — chaque carte de panneau a une liste déroulante pour choisir lequel afficher.', 'amp.none': 'Aucun amplificateur configuré.', 'amp.namePh': 'Nom (p. ex. SPE gauche)', 'amp.remove': 'Supprimer cet amplificateur', 'amp.add': 'Ajouter un amplificateur', 'amp.freqOut': "Envoyer la fréquence à l'amplificateur (suivi de bande)", 'amp.freqPort': 'Port COM CAT/AUX', 'amp.freqBroadcast': 'Envoyer aussi sans être interrogé', 'amp.freqPollOnly': "Non — répondre seulement à l'amplificateur", 'amp.freqEvery': 'Oui, toutes les {ms} ms', 'amp.freqHint': "OpsLog se fait passer pour un transceiver sur ce second port, au format Kenwood : réglez l'amplificateur sur ce jeu de commandes (le jeu 5 sur un ACOM) à la même vitesse, et mettez-le en OPERATE — en veille il acquitte mais ne change pas de bande. Un amplificateur qui INTERROGE (ACOM) n'a besoin de rien de plus ; un amplificateur qui se contente d'ÉCOUTER la liaison CAT de la radio n'entendra rien tant que l'envoi spontané n'est pas activé.",
|
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)', 'amp.hint': 'Configurez un ou plusieurs amplificateurs — chaque carte de panneau a une liste déroulante pour choisir lequel afficher.', 'amp.none': 'Aucun amplificateur configuré.', 'amp.namePh': 'Nom (p. ex. SPE gauche)', 'amp.remove': 'Supprimer cet amplificateur', 'amp.add': 'Ajouter un amplificateur', 'amp.password': 'Code distant', 'amp.passwordPh': 'vide en LAN', 'amp.passwordHint': "PowerGenius XL uniquement : nécessaire pour joindre l'ampli à distance — il annonce alors « AUTH » et refuse toute commande (« Unauthorized ») tant qu'on n'est pas identifié. Laisse vide sur le réseau local.", 'amp.freqOut': "Envoyer la fréquence à l'amplificateur (suivi de bande)", 'amp.freqPort': 'Port COM CAT/AUX', 'amp.freqBroadcast': 'Envoyer aussi sans être interrogé', 'amp.freqPollOnly': "Non — répondre seulement à l'amplificateur", 'amp.freqEvery': 'Oui, toutes les {ms} ms', 'amp.freqHint': "OpsLog se fait passer pour un transceiver sur ce second port, au format Kenwood : réglez l'amplificateur sur ce jeu de commandes (le jeu 5 sur un ACOM) à la même vitesse, et mettez-le en OPERATE — en veille il acquitte mais ne change pas de bande. Un amplificateur qui INTERROGE (ACOM) n'a besoin de rien de plus ; un amplificateur qui se contente d'ÉCOUTER la liaison CAT de la radio n'entendra rien tant que l'envoi spontané n'est pas activé.",
|
||||||
'gen.groupDigital': 'Regrouper les modes digitaux en un seul (style DXCC)', 'gen.groupDigitalHint': '(badges de la matrice + cluster : FT8/FT4/RTTY… comptent comme un seul mode Digital ; décoché = chaque mode digital est un slot distinct)',
|
'gen.groupDigital': 'Regrouper les modes digitaux en un seul (style DXCC)', 'gen.groupDigitalHint': '(badges de la matrice + cluster : FT8/FT4/RTTY… comptent comme un seul mode Digital ; décoché = chaque mode digital est un slot distinct)',
|
||||||
// Chiffrement des mots de passe
|
// Chiffrement des mots de passe
|
||||||
'gen.pwEnc': 'Chiffrement des mots de passe',
|
'gen.pwEnc': 'Chiffrement des mots de passe',
|
||||||
@@ -672,6 +674,8 @@ const fr: Dict = {
|
|||||||
'clu.moveUp': 'Monter', 'clu.moveDown': 'Descendre', 'clu.edit': 'Éditer', 'clu.delete': 'Supprimer', 'clu.none': 'Aucun nœud cluster enregistré.', 'clu.connect': 'Connecter', 'clu.disconnect': 'Déconnecter',
|
'clu.moveUp': 'Monter', 'clu.moveDown': 'Descendre', 'clu.edit': 'Éditer', 'clu.delete': 'Supprimer', 'clu.none': 'Aucun nœud cluster enregistré.', 'clu.connect': 'Connecter', 'clu.disconnect': 'Déconnecter',
|
||||||
'clu.add': 'Ajouter cluster', 'clu.connectAll': 'Tout connecter', 'clu.disconnectAll': 'Tout déconnecter', 'clu.autoConnect': 'Connexion auto de tous les activés au démarrage',
|
'clu.add': 'Ajouter cluster', 'clu.connectAll': 'Tout connecter', 'clu.disconnectAll': 'Tout déconnecter', 'clu.autoConnect': 'Connexion auto de tous les activés au démarrage',
|
||||||
'clu.freeNodes': 'Nœuds publics gratuits :',
|
'clu.freeNodes': 'Nœuds publics gratuits :',
|
||||||
|
'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot',
|
||||||
|
'clu.workedSameSlotHint': '— un spot n\'affiche « contacté » que si vous avez contacté cet indicatif sur la MÊME bande et le MÊME mode, pas juste n\'importe où. Se combine avec le groupage des modes numériques (Réglages → Général) : activé, un indicatif contacté en 20m FT8 compte aussi comme contacté pour un spot 20m FT4 ; désactivé, FT8 et FT4 sont des slots distincts.',
|
||||||
'bk.hintMysql': "À la fermeture (1×/jour) OpsLog sauvegarde le SQLite local (config) ET exporte le log MySQL partagé en ADIF — opslog-log-<date>.adi — pour protéger tes contacts même s'ils sont sur le serveur. La rotation garde les N derniers de chaque.",
|
'bk.hintMysql': "À la fermeture (1×/jour) OpsLog sauvegarde le SQLite local (config) ET exporte le log MySQL partagé en ADIF — opslog-log-<date>.adi — pour protéger tes contacts même s'ils sont sur le serveur. La rotation garde les N derniers de chaque.",
|
||||||
'bk.hint': "OpsLog peut copier la base SQLite dans un dossier de ton choix à la fermeture, une fois par jour. La rotation garde les N dernières copies et supprime les plus anciennes.",
|
'bk.hint': "OpsLog peut copier la base SQLite dans un dossier de ton choix à la fermeture, une fois par jour. La rotation garde les N dernières copies et supprime les plus anciennes.",
|
||||||
'bk.auto': 'Sauvegarde auto à la fermeture d\'OpsLog (max 1×/jour)', 'bk.folder': 'Dossier de sauvegarde', 'bk.folderPh': 'vide = dossier par défaut', 'bk.browse': 'Parcourir…',
|
'bk.auto': 'Sauvegarde auto à la fermeture d\'OpsLog (max 1×/jour)', 'bk.folder': 'Dossier de sauvegarde', 'bk.folderPh': 'vide = dossier par défaut', 'bk.browse': 'Parcourir…',
|
||||||
@@ -681,10 +685,10 @@ const fr: Dict = {
|
|||||||
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
|
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
|
||||||
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||||
'tg2.hint': "OpsLog dialogue avec le 4O3A Tuner Genius XL en TCP (port fixé à 9010), seule l'IP de l'appareil est nécessaire. Un widget ancré affiche le ROS et la puissance directe et propose Accord, Bypass et Operate/Standby. Pilotage direct (pas via la radio) pour n'utiliser qu'une des connexions de la boîte.", 'tg2.enable': "Activer le contrôle du Tuner Genius", 'tg2.portHint': "Le port TCP est fixé à 9010 sur l'appareil.", 'tg2.password': 'Code distant', 'tg2.passwordPh': 'vide en LAN', 'tg2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
'tg2.hint': "OpsLog dialogue avec le 4O3A Tuner Genius XL en TCP (port fixé à 9010), seule l'IP de l'appareil est nécessaire. Un widget ancré affiche le ROS et la puissance directe et propose Accord, Bypass et Operate/Standby. Pilotage direct (pas via la radio) pour n'utiliser qu'une des connexions de la boîte.", 'tg2.enable': "Activer le contrôle du Tuner Genius", 'tg2.portHint': "Le port TCP est fixé à 9010 sur l'appareil.", 'tg2.password': 'Code distant', 'tg2.passwordPh': 'vide en LAN', 'tg2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||||
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.dual': 'Deux rotors', 'rot.rotor1': 'Rotor 1', 'rot.rotor2': 'Rotor 2', 'rot.name': 'Nom', 'rot.antenna': 'Antenne', 'rot.antenna1': 'Antenne rotor 1', 'rot.antenna2': 'Antenne rotor 2', 'rot.name1': 'Nom du rotor 1', 'rot.name2': 'Nom du rotor 2', 'rot.antStd': 'Antenne standard', 'rot.antMotor': 'Motorisée (Ultrabeam/SteppIR)', 'rot.add': 'Ajouter un rotor', 'rot.remove': 'Supprimer le rotor', 'rot.none': 'Aucun rotor configuré. Cliquez sur « Ajouter un rotor ».', 'rot.rgDual': 'Ce Rotator Genius pilote deux rotors (en ajoute un second)', 'rot.testRotor1': 'Tester rotor 1', 'rot.testRotor2': 'Tester rotor 2', 'rot.dualHint': 'Deux rotors indépendants de n\'importe quel type — par exemple un ARCO et un Rotator Genius côte à côte. Configurez chacun ci-dessous ; OpsLog affiche un sélecteur Rotor 1/2 sur le compas pour choisir celui qu\'il tourne et affiche. Réglez chaque rotor sur « Motorisée » pour que les tracés de boom/diagramme (inverse, bidirectionnel) n\'apparaissent que pour celui qui porte l\'Ultrabeam/SteppIR. (Pour un seul Rotator Genius pilotant deux rotors, réglez les deux sur Rotator Genius avec le même hôte et le rotator n° 1 et 2.)', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
||||||
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
||||||
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
||||||
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.xieguPTTLine': 'Passage en \u00e9mission', 'cat.xieguPTTCiv': 'Commande CI-V', 'cat.xieguPTTHint': 'Un G90 ne passe pas en \u00e9mission sur la commande CI-V : les interfaces comme le DE-19 le pilotent par RTS ou DTR. Choisissez la ligne de la v\u00f4tre \u2014 c\u2019est aussi ce qui permet \u00e0 WSJT-X d\u2019\u00e9mettre via le partage CAT.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.lowerLines': 'Abaisser les lignes DTR et RTS à la connexion', 'cat.lowerLinesHint': 'Windows les lève à l’ouverture du port, et certaines interfaces lisent l’une ou l’autre comme PTT — la radio émet alors tant qu’OpsLog tourne. À cocher seulement dans ce cas : d’autres interfaces ont besoin de RTS haut pour émettre, et l’abaisser rend la radio muette.', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 puissance par bande et par mode', 'flxpw.hint': 'Puissance d\u2019\u00e9mission appliqu\u00e9e au changement de bande ou de mode. Laissez une case vide pour ne pas toucher \u00e0 la puissance.', 'flxpw.band': 'Bande', 'flxb.title': 'FlexRadio \u2014 antennes et puissance par bande', 'flxb.hint': 'Les antennes sont appliqu\u00e9es au changement de bande ; la puissance au changement de bande ou de mode. Laissez une case de puissance vide pour ne pas y toucher.', 'flxb.rxAnt': 'Antenne RX', 'flxb.txAnt': 'Antenne TX', 'flxb.noRadio': 'Aucune antenne remont\u00e9e \u2014 connectez le FlexRadio, puis rouvrez ce panneau.', 'flxpw.phone': 'Phonie', 'flxpw.digi': 'Num\u00e9rique', 'flxpw.noBands': 'Aucune bande configur\u00e9e \u2014 ajoutez-les dans Listes \u2192 Bandes.', 'flxpw.saved': 'Enregistr\u00e9',
|
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.pttKey': 'Activer la touche PTT', 'cat.pttKeyPress': 'Appuyez sur une touche…', 'cat.pttKeyNone': 'Cliquez pour définir une touche', 'cat.pttKeyClear': 'Effacer', 'cat.pttKeyToggle': 'Mode bascule (appui = émission, nouvel appui = arrêt)', 'cat.pttKeyHint': "Quand OpsLog a le focus, cette touche passe la radio en émission — maintenue par défaut (relâcher pour arrêter), ou verrouillée en mode bascule. Elle utilise la méthode PTT de Audio → PTT (CAT / RTS / DTR), avec repli sur le CAT. Choisissez une touche que vous ne tapez jamais en journalisant (ex. Pause, Arrêt défil., ou une pédale mappée dessus) — OpsLog l'intercepte pour qu'elle n'atterrisse pas dans un champ.", 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.xieguPTTLine': 'Passage en \u00e9mission', 'cat.xieguPTTCiv': 'Commande CI-V', 'cat.xieguPTTHint': 'Un G90 ne passe pas en \u00e9mission sur la commande CI-V : les interfaces comme le DE-19 le pilotent par RTS ou DTR. Choisissez la ligne de la v\u00f4tre \u2014 c\u2019est aussi ce qui permet \u00e0 WSJT-X d\u2019\u00e9mettre via le partage CAT.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.lowerLines': 'Abaisser les lignes DTR et RTS à la connexion', 'cat.lowerLinesHint': 'Si votre radio est toujours en émission, cochez ceci.', 'cat.kwDataMode': 'Modes data (FT8/PSK…)', 'cat.kwDataUsb': 'USB', 'cat.kwDataMd6': 'Mode DATA — MD6 (Elecraft K3/K4)', 'cat.kwDataKeep': 'Ne pas changer le mode de la radio', 'cat.kwDataHint': "Ce qu'OpsLog règle sur la radio pour un mode data. Aucune commande unique ne convient à toutes : un Elecraft K3/K4 veut DATA (MD6) ; sur un TS-590SG/TS-990S le mode data est un modificateur d'USB réglé sur la radio, choisissez donc USB ou, plus sûr, « Ne pas changer » et passez la radio en DATA vous-même. En MD6 un Kenwood classique (TS-590/990) tomberait en FSK/RTTY — à ne pas utiliser là.", 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 puissance par bande et par mode', 'flxpw.hint': 'Puissance d\u2019\u00e9mission appliqu\u00e9e au changement de bande ou de mode. Laissez une case vide pour ne pas toucher \u00e0 la puissance.', 'flxpw.band': 'Bande', 'flxb.title': 'FlexRadio \u2014 antennes et puissance par bande', 'flxb.hint': 'Les antennes sont appliqu\u00e9es au changement de bande ; la puissance au changement de bande ou de mode. Laissez une case de puissance vide pour ne pas y toucher.', 'flxb.rxAnt': 'Antenne RX', 'flxb.txAnt': 'Antenne TX', 'flxb.noRadio': 'Aucune antenne remont\u00e9e \u2014 connectez le FlexRadio, puis rouvrez ce panneau.', 'flxpw.phone': 'Phonie', 'flxpw.digi': 'Num\u00e9rique', 'flxpw.noBands': 'Aucune bande configur\u00e9e \u2014 ajoutez-les dans Listes \u2192 Bandes.', 'flxpw.saved': 'Enregistr\u00e9',
|
||||||
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
||||||
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
||||||
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||||
@@ -775,7 +779,7 @@ const fr: Dict = {
|
|||||||
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.newCounty': 'NOUV', 'detp.newCountyTip': 'Comté jamais contacté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'CQ', 'detp.ituZone': 'ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.ambiguous': 'Ambigu — choisissez :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
|
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.newCounty': 'NOUV', 'detp.newCountyTip': 'Comté jamais contacté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'CQ', 'detp.ituZone': 'ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.ambiguous': 'Ambigu — choisissez :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
|
||||||
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
|
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
|
||||||
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
|
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
|
||||||
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.modePhone': 'Phonie', 'awp.modeDigital': 'Numérique', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.modePhone': 'Phonie', 'awp.modeDigital': 'Numérique', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.prefixCol': 'Préfixe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
||||||
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.oneRef': 'Une seule référence par QSO', 'awed.oneRefHint': 'Quand plusieurs références correspondent au même contact, n'+'’en affecter aucune et le lister dans les références manquantes. Pour les diplômes où deux entrées partagent une description — deux DOK nommés Gießen — en choisir une au hasard inscrirait la mauvaise dans le journal.', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
|
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.oneRef': 'Une seule référence par QSO', 'awed.oneRefHint': 'Quand plusieurs références correspondent au même contact, n'+'’en affecter aucune et le lister dans les références manquantes. Pour les diplômes où deux entrées partagent une description — deux DOK nommés Gießen — en choisir une au hasard inscrirait la mauvaise dans le journal.', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
|
||||||
'awed.updateAvailable': 'Une nouvelle version de ce diplôme est disponible', 'awed.updateOverwrites': "Tu as modifié ce diplôme, la mise à jour n'a donc pas été appliquée. L'accepter remplacera ta définition et ta liste de références.", 'awed.updateApply': 'Mettre à jour', 'awed.updateKeepMine': 'Garder les miennes',
|
'awed.updateAvailable': 'Une nouvelle version de ce diplôme est disponible', 'awed.updateOverwrites': "Tu as modifié ce diplôme, la mise à jour n'a donc pas été appliquée. L'accepter remplacera ta définition et ta liste de références.", 'awed.updateApply': 'Mettre à jour', 'awed.updateKeepMine': 'Garder les miennes',
|
||||||
'awed.tabTest': 'Test', 'awed.testCallsign': 'Tester avec un indicatif', 'awed.testRun': 'Tester', 'awed.testSavedOnly': 'Teste le diplôme ENREGISTRÉ — enregistre tes modifications avant.', 'awed.testNoMatch': 'aucune correspondance', 'awed.testOutOfScope': "QSO hors périmètre — aucune règle n'a été exécutée.", 'awed.testSkipped': "non exécutée : une règle précédente a déjà trouvé", 'awed.testFieldValue': 'Champ', 'awed.testEmptyField': 'vide', 'awed.testNoCandidate': "n'a produit aucun candidat", 'awed.testManual': 'Référence forcée à la main', 'awed.testAmbiguous': 'Ambigu', 'awed.testAmbiguousHint': '— aucune retenue, ce diplôme n’admet qu’une référence par QSO. Affectez la bonne à la main.', 'awed.testSameAs': '+{n} autre(s) QSO, même résultat',
|
'awed.tabTest': 'Test', 'awed.testCallsign': 'Tester avec un indicatif', 'awed.testRun': 'Tester', 'awed.testSavedOnly': 'Teste le diplôme ENREGISTRÉ — enregistre tes modifications avant.', 'awed.testNoMatch': 'aucune correspondance', 'awed.testOutOfScope': "QSO hors périmètre — aucune règle n'a été exécutée.", 'awed.testSkipped': "non exécutée : une règle précédente a déjà trouvé", 'awed.testFieldValue': 'Champ', 'awed.testEmptyField': 'vide', 'awed.testNoCandidate': "n'a produit aucun candidat", 'awed.testManual': 'Référence forcée à la main', 'awed.testAmbiguous': 'Ambigu', 'awed.testAmbiguousHint': '— aucune retenue, ce diplôme n’admet qu’une référence par QSO. Affectez la bonne à la main.', 'awed.testSameAs': '+{n} autre(s) QSO, même résultat',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.23.2';
|
export const APP_VERSION = '0.23.5';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+19
-3
@@ -87,6 +87,8 @@ export function AwardsFolder():Promise<string>;
|
|||||||
|
|
||||||
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
|
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
|
||||||
|
|
||||||
|
export function BandSlotQSOs(arg1:string,arg2:number,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function BrowseExecutable():Promise<string>;
|
export function BrowseExecutable():Promise<string>;
|
||||||
|
|
||||||
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
||||||
@@ -97,6 +99,8 @@ export function CIVTraceEnabled():Promise<boolean>;
|
|||||||
|
|
||||||
export function CWDecoderRunning():Promise<boolean>;
|
export function CWDecoderRunning():Promise<boolean>;
|
||||||
|
|
||||||
|
export function CancelConfirmations():Promise<void>;
|
||||||
|
|
||||||
export function ChatAvailable():Promise<boolean>;
|
export function ChatAvailable():Promise<boolean>;
|
||||||
|
|
||||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||||
@@ -469,7 +473,7 @@ export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
|||||||
|
|
||||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||||
|
|
||||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
||||||
|
|
||||||
export function GetSPEStatus():Promise<spe.Status>;
|
export function GetSPEStatus():Promise<spe.Status>;
|
||||||
|
|
||||||
@@ -607,6 +611,10 @@ export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
|||||||
|
|
||||||
export function IsNewUSCounty(arg1:string,arg2:string):Promise<boolean>;
|
export function IsNewUSCounty(arg1:string,arg2:string):Promise<boolean>;
|
||||||
|
|
||||||
|
export function KenwoodSendCW(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function KenwoodStopCW():Promise<void>;
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||||
|
|
||||||
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||||
@@ -707,6 +715,10 @@ export function PGXLSetFanMode(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function PGXLSetOperate(arg1:boolean):Promise<void>;
|
export function PGXLSetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function PTTHotkeyDown():Promise<void>;
|
||||||
|
|
||||||
|
export function PTTHotkeyUp():Promise<void>;
|
||||||
|
|
||||||
export function PickADIFMonitorFile():Promise<string>;
|
export function PickADIFMonitorFile():Promise<string>;
|
||||||
|
|
||||||
export function PickAudioFolder():Promise<string>;
|
export function PickAudioFolder():Promise<string>;
|
||||||
@@ -879,7 +891,7 @@ export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
|||||||
|
|
||||||
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
|
||||||
|
|
||||||
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||||
|
|
||||||
@@ -909,6 +921,8 @@ export function SendLogToDeveloper():Promise<void>;
|
|||||||
|
|
||||||
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetActiveRotor(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||||
@@ -929,6 +943,8 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetPassphrase(arg1:string):Promise<void>;
|
export function SetPassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||||
@@ -1011,7 +1027,7 @@ export function TestPTT(arg1:main.AudioSettings):Promise<void>;
|
|||||||
|
|
||||||
export function TestQRZUpload():Promise<string>;
|
export function TestQRZUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ export function BackfillUSCounties() {
|
|||||||
return window['go']['main']['App']['BackfillUSCounties']();
|
return window['go']['main']['App']['BackfillUSCounties']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BandSlotQSOs(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['main']['App']['BandSlotQSOs'](arg1, arg2, arg3, arg4);
|
||||||
|
}
|
||||||
|
|
||||||
export function BrowseExecutable() {
|
export function BrowseExecutable() {
|
||||||
return window['go']['main']['App']['BrowseExecutable']();
|
return window['go']['main']['App']['BrowseExecutable']();
|
||||||
}
|
}
|
||||||
@@ -142,6 +146,10 @@ export function CWDecoderRunning() {
|
|||||||
return window['go']['main']['App']['CWDecoderRunning']();
|
return window['go']['main']['App']['CWDecoderRunning']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CancelConfirmations() {
|
||||||
|
return window['go']['main']['App']['CancelConfirmations']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatAvailable() {
|
export function ChatAvailable() {
|
||||||
return window['go']['main']['App']['ChatAvailable']();
|
return window['go']['main']['App']['ChatAvailable']();
|
||||||
}
|
}
|
||||||
@@ -886,8 +894,8 @@ export function GetRotatorHeading() {
|
|||||||
return window['go']['main']['App']['GetRotatorHeading']();
|
return window['go']['main']['App']['GetRotatorHeading']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetRotatorSettings() {
|
export function GetRotators() {
|
||||||
return window['go']['main']['App']['GetRotatorSettings']();
|
return window['go']['main']['App']['GetRotators']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetSPEStatus() {
|
export function GetSPEStatus() {
|
||||||
@@ -1162,6 +1170,14 @@ export function IsNewUSCounty(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['IsNewUSCounty'](arg1, arg2);
|
return window['go']['main']['App']['IsNewUSCounty'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function KenwoodSendCW(arg1) {
|
||||||
|
return window['go']['main']['App']['KenwoodSendCW'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KenwoodStopCW() {
|
||||||
|
return window['go']['main']['App']['KenwoodStopCW']();
|
||||||
|
}
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1) {
|
export function LaunchAutostartProgram(arg1) {
|
||||||
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1362,6 +1378,14 @@ export function PGXLSetOperate(arg1) {
|
|||||||
return window['go']['main']['App']['PGXLSetOperate'](arg1);
|
return window['go']['main']['App']['PGXLSetOperate'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PTTHotkeyDown() {
|
||||||
|
return window['go']['main']['App']['PTTHotkeyDown']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PTTHotkeyUp() {
|
||||||
|
return window['go']['main']['App']['PTTHotkeyUp']();
|
||||||
|
}
|
||||||
|
|
||||||
export function PickADIFMonitorFile() {
|
export function PickADIFMonitorFile() {
|
||||||
return window['go']['main']['App']['PickADIFMonitorFile']();
|
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||||
}
|
}
|
||||||
@@ -1706,8 +1730,8 @@ export function SaveRelayAuto(arg1) {
|
|||||||
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1) {
|
export function SaveRotators(arg1) {
|
||||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
return window['go']['main']['App']['SaveRotators'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SaveStationDevices(arg1) {
|
export function SaveStationDevices(arg1) {
|
||||||
@@ -1766,6 +1790,10 @@ export function SendQSORecordingEmail(arg1) {
|
|||||||
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetActiveRotor(arg1) {
|
||||||
|
return window['go']['main']['App']['SetActiveRotor'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetAlertEmailTo(arg1) {
|
export function SetAlertEmailTo(arg1) {
|
||||||
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
|
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1806,6 +1834,10 @@ export function SetDVKLabel(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetKenwoodKeySpeed(arg1) {
|
||||||
|
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetPassphrase(arg1) {
|
export function SetPassphrase(arg1) {
|
||||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1970,8 +2002,8 @@ export function TestQRZUpload() {
|
|||||||
return window['go']['main']['App']['TestQRZUpload']();
|
return window['go']['main']['App']['TestQRZUpload']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestRotator(arg1) {
|
export function TestRotatorDevice(arg1, arg2) {
|
||||||
return window['go']['main']['App']['TestRotator'](arg1);
|
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestStationDevice(arg1) {
|
export function TestStationDevice(arg1) {
|
||||||
|
|||||||
@@ -1520,6 +1520,7 @@ export namespace main {
|
|||||||
transport: string;
|
transport: string;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
password: string;
|
||||||
com_port: string;
|
com_port: string;
|
||||||
baud: number;
|
baud: number;
|
||||||
freq_out: boolean;
|
freq_out: boolean;
|
||||||
@@ -1540,6 +1541,7 @@ export namespace main {
|
|||||||
this.transport = source["transport"];
|
this.transport = source["transport"];
|
||||||
this.host = source["host"];
|
this.host = source["host"];
|
||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
|
this.password = source["password"];
|
||||||
this.com_port = source["com_port"];
|
this.com_port = source["com_port"];
|
||||||
this.baud = source["baud"];
|
this.baud = source["baud"];
|
||||||
this.freq_out = source["freq_out"];
|
this.freq_out = source["freq_out"];
|
||||||
@@ -1928,6 +1930,7 @@ export namespace main {
|
|||||||
kenwood_host: string;
|
kenwood_host: string;
|
||||||
kenwood_port: string;
|
kenwood_port: string;
|
||||||
kenwood_baud: number;
|
kenwood_baud: number;
|
||||||
|
kenwood_data_mode: string;
|
||||||
yaesu_low_lines: boolean;
|
yaesu_low_lines: boolean;
|
||||||
kenwood_low_lines: boolean;
|
kenwood_low_lines: boolean;
|
||||||
icom_port: string;
|
icom_port: string;
|
||||||
@@ -1945,6 +1948,9 @@ export namespace main {
|
|||||||
digital_default: string;
|
digital_default: string;
|
||||||
share_enabled: boolean;
|
share_enabled: boolean;
|
||||||
share_port: number;
|
share_port: number;
|
||||||
|
ptt_hotkey_enabled: boolean;
|
||||||
|
ptt_hotkey: string;
|
||||||
|
ptt_hotkey_toggle: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new CATSettings(source);
|
return new CATSettings(source);
|
||||||
@@ -1970,6 +1976,7 @@ export namespace main {
|
|||||||
this.kenwood_host = source["kenwood_host"];
|
this.kenwood_host = source["kenwood_host"];
|
||||||
this.kenwood_port = source["kenwood_port"];
|
this.kenwood_port = source["kenwood_port"];
|
||||||
this.kenwood_baud = source["kenwood_baud"];
|
this.kenwood_baud = source["kenwood_baud"];
|
||||||
|
this.kenwood_data_mode = source["kenwood_data_mode"];
|
||||||
this.yaesu_low_lines = source["yaesu_low_lines"];
|
this.yaesu_low_lines = source["yaesu_low_lines"];
|
||||||
this.kenwood_low_lines = source["kenwood_low_lines"];
|
this.kenwood_low_lines = source["kenwood_low_lines"];
|
||||||
this.icom_port = source["icom_port"];
|
this.icom_port = source["icom_port"];
|
||||||
@@ -1987,6 +1994,9 @@ export namespace main {
|
|||||||
this.digital_default = source["digital_default"];
|
this.digital_default = source["digital_default"];
|
||||||
this.share_enabled = source["share_enabled"];
|
this.share_enabled = source["share_enabled"];
|
||||||
this.share_port = source["share_port"];
|
this.share_port = source["share_port"];
|
||||||
|
this.ptt_hotkey_enabled = source["ptt_hotkey_enabled"];
|
||||||
|
this.ptt_hotkey = source["ptt_hotkey"];
|
||||||
|
this.ptt_hotkey_toggle = source["ptt_hotkey_toggle"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class CabrilloResult {
|
export class CabrilloResult {
|
||||||
@@ -2804,11 +2814,52 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class RotatorDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
has_elevation: boolean;
|
||||||
|
rotator_num: number;
|
||||||
|
dual: boolean;
|
||||||
|
motorized: boolean;
|
||||||
|
name2: string;
|
||||||
|
motorized2: boolean;
|
||||||
|
transport: string;
|
||||||
|
com_port: string;
|
||||||
|
baud: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RotatorDevice(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.port = source["port"];
|
||||||
|
this.has_elevation = source["has_elevation"];
|
||||||
|
this.rotator_num = source["rotator_num"];
|
||||||
|
this.dual = source["dual"];
|
||||||
|
this.motorized = source["motorized"];
|
||||||
|
this.name2 = source["name2"];
|
||||||
|
this.motorized2 = source["motorized2"];
|
||||||
|
this.transport = source["transport"];
|
||||||
|
this.com_port = source["com_port"];
|
||||||
|
this.baud = source["baud"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class RotatorHeading {
|
export class RotatorHeading {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
azimuth: number;
|
azimuth: number;
|
||||||
raw: string;
|
raw: string;
|
||||||
|
rotors: string[];
|
||||||
|
active: number;
|
||||||
|
motorized: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new RotatorHeading(source);
|
return new RotatorHeading(source);
|
||||||
@@ -2820,34 +2871,9 @@ export namespace main {
|
|||||||
this.ok = source["ok"];
|
this.ok = source["ok"];
|
||||||
this.azimuth = source["azimuth"];
|
this.azimuth = source["azimuth"];
|
||||||
this.raw = source["raw"];
|
this.raw = source["raw"];
|
||||||
}
|
this.rotors = source["rotors"];
|
||||||
}
|
this.active = source["active"];
|
||||||
export class RotatorSettings {
|
this.motorized = source["motorized"];
|
||||||
enabled: boolean;
|
|
||||||
type: string;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
has_elevation: boolean;
|
|
||||||
rotator_num: number;
|
|
||||||
transport: string;
|
|
||||||
com_port: string;
|
|
||||||
baud: number;
|
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
|
||||||
return new RotatorSettings(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
|
||||||
this.enabled = source["enabled"];
|
|
||||||
this.type = source["type"];
|
|
||||||
this.host = source["host"];
|
|
||||||
this.port = source["port"];
|
|
||||||
this.has_elevation = source["has_elevation"];
|
|
||||||
this.rotator_num = source["rotator_num"];
|
|
||||||
this.transport = source["transport"];
|
|
||||||
this.com_port = source["com_port"];
|
|
||||||
this.baud = source["baud"];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class ScpStatus {
|
export class ScpStatus {
|
||||||
|
|||||||
@@ -869,3 +869,22 @@ func (m *Manager) YaesuDo(fn func(YaesuController) error) error {
|
|||||||
return fn(yc)
|
return fn(yc)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KenwoodController is the Kenwood/Elecraft CW-over-CAT capability (the KY keyer),
|
||||||
|
// so a K3 can key CW through its single CAT link instead of a second COM port.
|
||||||
|
type KenwoodController interface {
|
||||||
|
SendCW(string) error
|
||||||
|
StopCW() error
|
||||||
|
SetKeySpeed(int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// KenwoodDo dispatches a Kenwood control onto the CAT goroutine.
|
||||||
|
func (m *Manager) KenwoodDo(fn func(KenwoodController) error) error {
|
||||||
|
return m.exec(func(b Backend) error {
|
||||||
|
kc, ok := b.(KenwoodController)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("active CAT backend is not a Kenwood/Elecraft")
|
||||||
|
}
|
||||||
|
return fn(kc)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+17
-2
@@ -62,8 +62,10 @@ type Flex struct {
|
|||||||
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
||||||
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
||||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||||
|
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
|
||||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||||
|
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
||||||
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
||||||
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||||
|
|
||||||
@@ -185,7 +187,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
|||||||
return &Flex{
|
return &Flex{
|
||||||
host: strings.TrimSpace(host), port: port,
|
host: strings.TrimSpace(host), port: port,
|
||||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||||
pinnedSlice: -1,
|
pinnedSlice: -1,
|
||||||
@@ -338,11 +340,17 @@ func (f *Flex) reader(conn net.Conn) {
|
|||||||
if idx, err := strconv.Atoi(mm[1]); err == nil {
|
if idx, err := strconv.Atoi(mm[1]); err == nil {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
call := f.spotCall[idx]
|
call := f.spotCall[idx]
|
||||||
|
mode := f.spotMode[idx]
|
||||||
handler := f.OnSpotClick
|
handler := f.OnSpotClick
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if call != "" && handler != nil {
|
if call != "" && handler != nil {
|
||||||
debugLog.Printf("Flex: spot %d triggered → %s", idx, call)
|
debugLog.Printf("Flex: spot %d triggered → %s (mode %s)", idx, call, mode)
|
||||||
go handler(call, 0)
|
go handler(call, 0)
|
||||||
|
// SmartSDR tunes the spot's frequency on click but does NOT apply
|
||||||
|
// its mode=, so set the slice mode ourselves from what we spotted.
|
||||||
|
if mode != "" {
|
||||||
|
go func(m string) { _ = f.SetMode(m) }(mode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,12 +387,15 @@ func (f *Flex) reader(conn net.Conn) {
|
|||||||
// pair it with the callsign we stashed under this seq.
|
// pair it with the callsign we stashed under this seq.
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
call, pending := f.pendingSpot[seq]
|
call, pending := f.pendingSpot[seq]
|
||||||
|
spotMode := f.pendingSpotMode[seq]
|
||||||
if pending {
|
if pending {
|
||||||
delete(f.pendingSpot, seq)
|
delete(f.pendingSpot, seq)
|
||||||
|
delete(f.pendingSpotMode, seq)
|
||||||
}
|
}
|
||||||
if pending && ok && len(parts) >= 3 {
|
if pending && ok && len(parts) >= 3 {
|
||||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||||
f.spotCall[idx] = call
|
f.spotCall[idx] = call
|
||||||
|
f.spotMode[idx] = spotMode
|
||||||
f.spotIdx[idx] = true
|
f.spotIdx[idx] = true
|
||||||
f.spotByCall[strings.ToUpper(call)] = idx
|
f.spotByCall[strings.ToUpper(call)] = idx
|
||||||
}
|
}
|
||||||
@@ -801,6 +812,7 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
if removed {
|
if removed {
|
||||||
delete(f.spotIdx, idx)
|
delete(f.spotIdx, idx)
|
||||||
delete(f.spotCall, idx)
|
delete(f.spotCall, idx)
|
||||||
|
delete(f.spotMode, idx)
|
||||||
} else {
|
} else {
|
||||||
f.spotIdx[idx] = true
|
f.spotIdx[idx] = true
|
||||||
}
|
}
|
||||||
@@ -1263,6 +1275,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
|||||||
if hadOld {
|
if hadOld {
|
||||||
delete(f.spotByCall, upperCall)
|
delete(f.spotByCall, upperCall)
|
||||||
delete(f.spotCall, old)
|
delete(f.spotCall, old)
|
||||||
|
delete(f.spotMode, old)
|
||||||
delete(f.spotIdx, old)
|
delete(f.spotIdx, old)
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
@@ -1288,6 +1301,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
|||||||
// (trigger) can be resolved back to the callsign.
|
// (trigger) can be resolved back to the callsign.
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.pendingSpot[seq] = s.Callsign
|
f.pendingSpot[seq] = s.Callsign
|
||||||
|
f.pendingSpotMode[seq] = s.Mode
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -1320,6 +1334,7 @@ func (f *Flex) ClearSpots() error {
|
|||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.spotIdx = map[int]bool{}
|
f.spotIdx = map[int]bool{}
|
||||||
f.spotCall = map[int]string{}
|
f.spotCall = map[int]string{}
|
||||||
|
f.spotMode = map[int]string{}
|
||||||
f.spotByCall = map[string]int{}
|
f.spotByCall = map[string]int{}
|
||||||
connected := f.conn != nil
|
connected := f.conn != nil
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
|||||||
+70
-11
@@ -56,6 +56,12 @@ type Kenwood struct {
|
|||||||
// a wire, which is how most operators actually put a rig on the network.
|
// a wire, which is how most operators actually put a rig on the network.
|
||||||
host string
|
host string
|
||||||
digital string // mode name logged for data (FT8 by default)
|
digital string // mode name logged for data (FT8 by default)
|
||||||
|
// dataMode chooses what CAT mode a DATA/digital mode (FT8, PSK…) sets, because
|
||||||
|
// no single Kenwood mode digit is right for every rig: "usb" → USB (the SSB-data
|
||||||
|
// base, default), "data" → the DATA mode digit MD6 (Elecraft K3/K4), "keep" →
|
||||||
|
// leave the rig's current mode untouched (safest for a TS-590SG/TS-990S whose
|
||||||
|
// data mode is a USB modifier the operator sets on the rig). See SetMode.
|
||||||
|
dataMode string
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
port serial.Port
|
port serial.Port
|
||||||
@@ -70,6 +76,15 @@ type Kenwood struct {
|
|||||||
curFreq int64
|
curFreq int64
|
||||||
curRXFreq int64
|
curRXFreq int64
|
||||||
curVFO string // "A" or "B"
|
curVFO string // "A" or "B"
|
||||||
|
// Split is confirmed with FR;/FT; because IF's split bit is empty on some
|
||||||
|
// Kenwood-dialect rigs — but that check is THROTTLED so it doesn't run every
|
||||||
|
// poll: the two extra commands tripled the poll time on a slow K3 and made the
|
||||||
|
// frequency (read from IF at the top of the poll) lag by seconds. Between
|
||||||
|
// checks the last result stands.
|
||||||
|
splitCheckAt time.Time
|
||||||
|
splitCached bool
|
||||||
|
splitVFOCached string
|
||||||
|
keyWPM int // CW keyer speed, for pacing the KY buffer (see kenwood_cw.go)
|
||||||
// Commands this rig answered "?;" to — asked once, then never again.
|
// Commands this rig answered "?;" to — asked once, then never again.
|
||||||
unsupported map[string]bool
|
unsupported map[string]bool
|
||||||
|
|
||||||
@@ -269,18 +284,31 @@ func (k *Kenwood) ReadState() (RigState, error) {
|
|||||||
// this costs two short commands per poll only where it actually works.
|
// this costs two short commands per poll only where it actually works.
|
||||||
split := f.Split
|
split := f.Split
|
||||||
if !split {
|
if !split {
|
||||||
|
// Confirm with FR;/FT; only once a second, not every poll. Running the two
|
||||||
|
// extra commands each cycle tripled the poll time on a slow K3, so the
|
||||||
|
// frequency — already read from IF above — only surfaced every couple of
|
||||||
|
// seconds. Between checks the last FR/FT result stands.
|
||||||
|
if time.Since(k.splitCheckAt) >= time.Second {
|
||||||
|
k.splitCheckAt = time.Now()
|
||||||
|
k.splitCached, k.splitVFOCached = false, ""
|
||||||
rxv, rxOK := k.askVFO("FR;")
|
rxv, rxOK := k.askVFO("FR;")
|
||||||
txv, txOK := k.askVFO("FT;")
|
txv, txOK := k.askVFO("FT;")
|
||||||
if rxOK && txOK && rxv != txv {
|
if rxOK && txOK && rxv != txv {
|
||||||
split = true
|
k.splitCached = true
|
||||||
// Trust FR over IF for which VFO is in use: they were asked in the
|
// Trust FR over IF for which VFO is in use: they were asked in the
|
||||||
// same breath, and a rig that leaves the split bit empty may be just
|
// same breath, and a rig that leaves the split bit empty may be just
|
||||||
// as vague about the VFO field.
|
// as vague about the VFO field. The block below picks the TRANSMIT
|
||||||
// f.VFO too, not just the reported state: the block below picks the
|
// VFO as "the other one" from f.VFO, so leaving them disagreeing
|
||||||
// TRANSMIT VFO as "the other one" from f.VFO, and leaving the two
|
// would read the transmit frequency off the wrong dial.
|
||||||
// disagreeing would read the transmit frequency off the wrong dial.
|
|
||||||
if rxv == "A" || rxv == "B" {
|
if rxv == "A" || rxv == "B" {
|
||||||
k.curVFO, s.Vfo, f.VFO = rxv, rxv, rxv
|
k.splitVFOCached = rxv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if k.splitCached {
|
||||||
|
split = true
|
||||||
|
if k.splitVFOCached != "" {
|
||||||
|
k.curVFO, s.Vfo, f.VFO = k.splitVFOCached, k.splitVFOCached, k.splitVFOCached
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,6 +366,17 @@ func (k *Kenwood) SetMode(mode string) error {
|
|||||||
if k.port == nil {
|
if k.port == nil {
|
||||||
return fmt.Errorf("kenwood: not connected")
|
return fmt.Errorf("kenwood: not connected")
|
||||||
}
|
}
|
||||||
|
// Honour the operator's DATA-mode preference for a digital mode. No single
|
||||||
|
// mode digit fits every rig, so the operator picks: keep the rig's mode, use
|
||||||
|
// MD6 (K3/K4 DATA), or fall through to the default USB from kenwoodModeDigit.
|
||||||
|
if isKenwoodDataMode(mode) {
|
||||||
|
switch k.dataMode {
|
||||||
|
case "keep":
|
||||||
|
return nil // leave whatever data mode the operator set on the rig
|
||||||
|
case "data":
|
||||||
|
return k.write("MD6;") // Elecraft K3/K4 DATA mode
|
||||||
|
}
|
||||||
|
}
|
||||||
d := kenwoodModeDigit(mode, k.curFreq)
|
d := kenwoodModeDigit(mode, k.curFreq)
|
||||||
if d == 0 {
|
if d == 0 {
|
||||||
return fmt.Errorf("kenwood: no CAT mode for %q", mode)
|
return fmt.Errorf("kenwood: no CAT mode for %q", mode)
|
||||||
@@ -345,6 +384,25 @@ func (k *Kenwood) SetMode(mode string) error {
|
|||||||
return k.write(fmt.Sprintf("MD%c;", d))
|
return k.write(fmt.Sprintf("MD%c;", d))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDataMode configures how a DATA/digital mode is set: "usb" (default), "data"
|
||||||
|
// (MD6, Elecraft K3/K4) or "keep" (don't change the rig's mode).
|
||||||
|
func (k *Kenwood) SetDataMode(m string) {
|
||||||
|
k.mu.Lock()
|
||||||
|
k.dataMode = strings.ToLower(strings.TrimSpace(m))
|
||||||
|
k.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// isKenwoodDataMode reports whether a mode name is a soundcard/data mode (FT8,
|
||||||
|
// PSK, JT…) rather than a voice/CW/RTTY mode the rig sets natively.
|
||||||
|
func isKenwoodDataMode(mode string) bool {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "", "LSB", "USB", "SSB", "CW", "CW-R", "CWR", "FM", "NFM", "AM",
|
||||||
|
"RTTY", "FSK", "RTTY-R", "FSK-R":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (k *Kenwood) SetPTT(on bool) error {
|
func (k *Kenwood) SetPTT(on bool) error {
|
||||||
k.mu.Lock()
|
k.mu.Lock()
|
||||||
defer k.mu.Unlock()
|
defer k.mu.Unlock()
|
||||||
@@ -529,11 +587,12 @@ func kenwoodModeDigit(mode string, hz int64) byte {
|
|||||||
}
|
}
|
||||||
return '2'
|
return '2'
|
||||||
}
|
}
|
||||||
// Any other digital mode rides on the data sideband, which on Kenwood is
|
// Any other digital mode (FT8, PSK, JT…) → USB on every band. There is NO single
|
||||||
// plain USB/LSB with the rig's DATA input selected.
|
// mode digit that is right for both families: a K3 wants DATA (MD6), a TS-590SG
|
||||||
if hz > 0 && hz < 10_000_000 {
|
// wants USB-DATA (MD2 + its DATA modifier), and MD6 on the TS-590SG is FSK/RTTY
|
||||||
return '1'
|
// — wrong for FT8. USB is the safe common base (the reversed-sideband "DATA REV"
|
||||||
}
|
// only came from the old LSB-below-10-MHz rule). A rig-specific DATA mode is a
|
||||||
|
// follow-up that needs the model, not a blind default.
|
||||||
return '2'
|
return '2'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
// CW keying through the Kenwood/Elecraft keyer — the KY command.
|
||||||
|
//
|
||||||
|
// Same idea as the Yaesu KY engine and the Icom / Flex keyers: the radio holds
|
||||||
|
// the text and keys it with its own timing, so an Elecraft K3 (or any rig that
|
||||||
|
// speaks this dialect) needs NO WinKeyer and NO second COM port — the single CAT
|
||||||
|
// link does frequency, mode AND CW. That matters on a K3, whose one USB port is
|
||||||
|
// the CAT port; a separate serial keyer would need a second cable OpsLog can't
|
||||||
|
// give it.
|
||||||
|
//
|
||||||
|
// KY; → KYn; n=0 buffer has room, n=1 buffer full
|
||||||
|
// KY <text>; queue up to 24 characters (the space after KY is part
|
||||||
|
// of the command, not padding)
|
||||||
|
// KS nnn; keyer speed in WPM (three digits)
|
||||||
|
// RX; drop to receive — used to abort a send
|
||||||
|
//
|
||||||
|
// KY is the Elecraft-documented CW-over-CAT path on the K3/K4. A rig that refuses
|
||||||
|
// it answers "?;", which SendCW turns into a stated reason rather than silence.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// kenwoodCWChunk is the most characters one KY command accepts.
|
||||||
|
const kenwoodCWChunk = 24
|
||||||
|
|
||||||
|
// kenwoodCWAllowed is what the keyer can send; anything else is dropped, since an
|
||||||
|
// unsupported byte can abort the buffer and lose the rest of the message.
|
||||||
|
const kenwoodCWAllowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /?.,-=+:;()"
|
||||||
|
|
||||||
|
// SendCW queues a message on the rig's keyer, fed in 24-character pieces, waiting
|
||||||
|
// for buffer room between pieces so a long macro doesn't lose its tail.
|
||||||
|
func (k *Kenwood) SendCW(text string) error {
|
||||||
|
msg := filterKenwoodCW(text)
|
||||||
|
if msg == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
for len(msg) > 0 {
|
||||||
|
n := kenwoodCWChunk
|
||||||
|
if len(msg) < n {
|
||||||
|
n = len(msg)
|
||||||
|
}
|
||||||
|
chunk := msg[:n]
|
||||||
|
msg = msg[n:]
|
||||||
|
k.waitCWBuffer(3 * time.Second)
|
||||||
|
if err := k.write("KY " + chunk + ";"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := k.afterKY(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Pace the next piece by how long this one takes to key, so we never overrun
|
||||||
|
// the 24-character buffer (the rig silently drops what doesn't fit).
|
||||||
|
if len(msg) > 0 {
|
||||||
|
time.Sleep(kenwoodCWDuration(chunk, k.keyerWPM()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// afterKY reads briefly after a KY write. An accepted KY says nothing; a REJECT
|
||||||
|
// answers "?;". Reading it straight off the port (not through the shared rx
|
||||||
|
// buffer) keeps that stray frame from being picked up by the next poll's ask —
|
||||||
|
// which would mis-mark an unrelated command unsupported and desync the link — and
|
||||||
|
// turns a silent non-transmission into a stated reason. The caller holds k.mu.
|
||||||
|
func (k *Kenwood) afterKY() error {
|
||||||
|
deadline := time.Now().Add(150 * time.Millisecond)
|
||||||
|
tmp := make([]byte, 64)
|
||||||
|
var buf []byte
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := k.port.Read(tmp)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
buf = append(buf, tmp[:n]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(string(buf), "?;") {
|
||||||
|
return fmt.Errorf("this radio rejected CW over CAT (it answered \"?;\" to KY). " +
|
||||||
|
"Switch the keyer engine to the serial-port keyer (DTR=CW) on a COM port instead")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitCWBuffer blocks until the keyer reports room, or the deadline passes. A rig
|
||||||
|
// that never answers the KY; status query is not a reason to refuse to send — we
|
||||||
|
// go ahead, and the per-chunk pacing covers the worst case. The caller holds k.mu.
|
||||||
|
func (k *Kenwood) waitCWBuffer(within time.Duration) {
|
||||||
|
deadline := time.Now().Add(within)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if k.unsupported["KY"] {
|
||||||
|
return // this rig doesn't report buffer state — pacing covers it
|
||||||
|
}
|
||||||
|
r, err := k.ask("KY;")
|
||||||
|
if err != nil {
|
||||||
|
return // unsupported / timeout — send anyway, pacing covers it
|
||||||
|
}
|
||||||
|
if !kenwoodCWBufferFull(r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// kenwoodCWBufferFull reads the KY; status reply. Deliberately asymmetric: only a
|
||||||
|
// clear "1" after KY means full. Anything else reads as "go ahead" — refusing to
|
||||||
|
// send because a status line was phrased unexpectedly is the worse failure.
|
||||||
|
func kenwoodCWBufferFull(reply string) bool {
|
||||||
|
r := strings.TrimSpace(reply)
|
||||||
|
if !strings.HasPrefix(strings.ToUpper(r), "KY") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range r[2:] {
|
||||||
|
switch c {
|
||||||
|
case '0':
|
||||||
|
return false
|
||||||
|
case '1':
|
||||||
|
return true
|
||||||
|
case ' ', ';':
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopCW aborts the message being sent. Kenwood documents no KY buffer-clear, so
|
||||||
|
// this drops the transmitter — RX; forces receive, which is what an operator
|
||||||
|
// pressing Escape wants; anything still queued is not keyed on the air.
|
||||||
|
func (k *Kenwood) StopCW() error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
return k.write("RX;")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKeySpeed sets the keyer speed (WPM) via KS and remembers it for pacing.
|
||||||
|
func (k *Kenwood) SetKeySpeed(wpm int) error {
|
||||||
|
if wpm < 4 {
|
||||||
|
wpm = 4
|
||||||
|
}
|
||||||
|
if wpm > 99 {
|
||||||
|
wpm = 99 // KS is three digits but the K3 keyer tops out well below 100
|
||||||
|
}
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
k.keyWPM = wpm
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
return k.write(fmt.Sprintf("KS%03d;", wpm))
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyerWPM is the speed to pace the buffer by. The caller holds k.mu.
|
||||||
|
func (k *Kenwood) keyerWPM() int {
|
||||||
|
if k.keyWPM >= 4 {
|
||||||
|
return k.keyWPM
|
||||||
|
}
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// kenwoodCWDuration estimates how long a piece of text takes to key (PARIS
|
||||||
|
// timing: a character averages 10 dits, a dit is 1.2/wpm seconds).
|
||||||
|
func kenwoodCWDuration(text string, wpm int) time.Duration {
|
||||||
|
if wpm < 4 {
|
||||||
|
wpm = 20
|
||||||
|
}
|
||||||
|
ditMs := 1200.0 / float64(wpm)
|
||||||
|
return time.Duration(float64(len(text))*10*ditMs) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterKenwoodCW upper-cases and strips what the keyer cannot send; whitespace
|
||||||
|
// becomes a single word gap.
|
||||||
|
func filterKenwoodCW(text string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToUpper(text) {
|
||||||
|
if r == '\t' || r == '\n' || r == '\r' {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.ContainsRune(kenwoodCWAllowed, r) {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(strings.Fields(b.String()), " ")
|
||||||
|
}
|
||||||
@@ -300,6 +300,11 @@ func TestKenwoodSplitFromFRFT(t *testing.T) {
|
|||||||
|
|
||||||
// Split on, IF still silent about it: receive on A, transmit on B.
|
// Split on, IF still silent about it: receive on A, transmit on B.
|
||||||
rig.split = true
|
rig.split = true
|
||||||
|
// The FR/FT split re-check is throttled to once a second (it tripled the poll
|
||||||
|
// time on a slow K3), so back-to-back reads reuse the last result. Simulate the
|
||||||
|
// recheck window having elapsed — in the field the 250 ms poll covers it inside
|
||||||
|
// a second.
|
||||||
|
k.splitCheckAt = time.Time{}
|
||||||
if s, err = k.ReadState(); err != nil {
|
if s, err = k.ReadState(); err != nil {
|
||||||
t.Fatalf("read: %v", err)
|
t.Fatalf("read: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ func TestKenwoodModeDigit(t *testing.T) {
|
|||||||
{"RTTY", 14080000, '6'},
|
{"RTTY", 14080000, '6'},
|
||||||
{"AM", 7150000, '5'},
|
{"AM", 7150000, '5'},
|
||||||
{"FM", 145000000, '4'},
|
{"FM", 145000000, '4'},
|
||||||
{"FT8", 7074000, '1'}, // data rides on the sideband for the band
|
{"FT8", 7074000, '2'}, // data is ALWAYS USB, even below 10 MHz (K3 "DATA REV" otherwise)
|
||||||
{"FT8", 14074000, '2'},
|
{"FT8", 14074000, '2'}, // …and above
|
||||||
{"", 14074000, 0}, // nothing to set
|
{"", 14074000, 0}, // nothing to set
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
@@ -111,6 +111,23 @@ func TestKenwoodModeDigit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The DATA-mode preference only applies to soundcard/data modes, never to voice,
|
||||||
|
// CW or native RTTY — getting this wrong would hijack an SSB or CW mode change.
|
||||||
|
func TestIsKenwoodDataMode(t *testing.T) {
|
||||||
|
data := []string{"FT8", "FT4", "PSK31", "JT65", "DATA", "DIGITAL", "OLIVIA"}
|
||||||
|
notData := []string{"", "LSB", "USB", "SSB", "CW", "CW-R", "FM", "AM", "RTTY", "FSK", "RTTY-R"}
|
||||||
|
for _, m := range data {
|
||||||
|
if !isKenwoodDataMode(m) {
|
||||||
|
t.Errorf("isKenwoodDataMode(%q) = false, want true", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range notData {
|
||||||
|
if isKenwoodDataMode(m) {
|
||||||
|
t.Errorf("isKenwoodDataMode(%q) = true, want false", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// What the log records for each mode digit the rig reports.
|
// What the log records for each mode digit the rig reports.
|
||||||
func TestKenwoodModeToADIF(t *testing.T) {
|
func TestKenwoodModeToADIF(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
|||||||
+71
-6
@@ -28,6 +28,12 @@ type TCI struct {
|
|||||||
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
|
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
|
||||||
spotsEnabled bool // mirror cluster spots onto the TCI panorama
|
spotsEnabled bool // mirror cluster spots onto the TCI panorama
|
||||||
|
|
||||||
|
// OnSpotClick is called when the user clicks one of our spots on the TCI
|
||||||
|
// panorama (callsign + freq), so the host can fill the entry form. Set before
|
||||||
|
// Connect. Mirrors the FlexRadio panadapter-click flow.
|
||||||
|
OnSpotClick func(callsign string, freqHz int64)
|
||||||
|
unhandledSeen map[string]bool // log each unknown TCI message type once
|
||||||
|
|
||||||
mu sync.Mutex // guards conn + writes + state
|
mu sync.Mutex // guards conn + writes + state
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
|
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
|
||||||
@@ -110,15 +116,32 @@ func (t *TCI) SendSpot(s SpotInfo) error {
|
|||||||
if call == "" || s.FreqHz <= 0 {
|
if call == "" || s.FreqHz <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
color := strings.TrimSpace(s.Color)
|
// TCI's SPOT command wants the colour as a signed 32-bit DECIMAL integer in
|
||||||
if color == "" {
|
// 0xAARRGGBB order — NOT a "0x…" hex string (e.g. "spot:UN7GK,cw,14025000,
|
||||||
color = "#FFFFA500" // opaque orange default
|
// -16776961,test;"). ExpertSDR silently drops a spot whose colour field it
|
||||||
|
// can't parse as a number, which is why spots never showed on the panorama
|
||||||
|
// while tuning (a separate command) still worked.
|
||||||
|
hex := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(s.Color), "#"), "0x")
|
||||||
|
if hex == "" {
|
||||||
|
hex = "FFFFA500" // opaque orange default
|
||||||
|
}
|
||||||
|
if len(hex) == 6 {
|
||||||
|
hex = "FF" + hex // add full-opacity alpha when only RGB was supplied
|
||||||
|
}
|
||||||
|
argb, err := strconv.ParseUint(hex, 16, 32)
|
||||||
|
if err != nil {
|
||||||
|
argb = 0xFFFFA500
|
||||||
|
}
|
||||||
|
// Use a valid TCI modulation (usb/lsb/cw/digl…) so ExpertSDR accepts the spot;
|
||||||
|
// fall back to the raw label if we can't map it. The click-to-tune path already
|
||||||
|
// maps the mode separately, so this only affects the spot's displayed mode.
|
||||||
|
mode := adifToTCIMode(s.Mode, s.FreqHz)
|
||||||
|
if mode == "" {
|
||||||
|
mode = strings.ToLower(strings.TrimSpace(s.Mode))
|
||||||
}
|
}
|
||||||
color = "0x" + strings.TrimPrefix(color, "#")
|
|
||||||
mode := strings.ToLower(strings.TrimSpace(s.Mode))
|
|
||||||
// Commas/semicolons would break TCI's comma-separated argument parsing.
|
// Commas/semicolons would break TCI's comma-separated argument parsing.
|
||||||
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
|
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
|
||||||
return t.send(fmt.Sprintf("spot:%s,%s,%d,%s,%s;", call, mode, s.FreqHz, color, text))
|
return t.send(fmt.Sprintf("spot:%s,%s,%d,%d,%s;", call, mode, s.FreqHz, int32(argb), text))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disconnect closes the WebSocket; the reader goroutine then exits.
|
// Disconnect closes the WebSocket; the reader goroutine then exits.
|
||||||
@@ -294,6 +317,48 @@ func (t *TCI) handle(msg string) {
|
|||||||
if get(0) == "0" {
|
if get(0) == "0" {
|
||||||
t.tx = get(1) == "true"
|
t.tx = get(1) == "true"
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
lname := strings.ToLower(name)
|
||||||
|
// A click on one of our panorama spots comes back as
|
||||||
|
// CLICKED_ON_SPOT:<call>,<hz> (legacy)
|
||||||
|
// RX_CLICKED_ON_SPOT:<rx>,<ch>,<call>,<hz>
|
||||||
|
// Neither name starts with "spot", which is why the click was silently
|
||||||
|
// ignored before. Read the callsign (the one non-numeric field) and the
|
||||||
|
// frequency (the large numeric field) positionally-independently, so both
|
||||||
|
// shapes work without depending on the exact arg order.
|
||||||
|
if strings.Contains(lname, "spot") {
|
||||||
|
var call string
|
||||||
|
var hz int64
|
||||||
|
for _, raw := range f {
|
||||||
|
v := strings.TrimSpace(raw)
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
if n >= 10000 { // a real frequency, not an rx/channel index
|
||||||
|
hz = n
|
||||||
|
}
|
||||||
|
} else if call == "" {
|
||||||
|
call = strings.ToUpper(v) // callsigns always carry letters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debugLog.Printf("TCI: spot click %q → call=%s freq=%d", msg, call, hz)
|
||||||
|
if call != "" && t.OnSpotClick != nil {
|
||||||
|
cb := t.OnSpotClick
|
||||||
|
go cb(call, hz)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Log every OTHER unknown message TYPE once, so the protocol (incl. any
|
||||||
|
// spot-click notification named differently) is discoverable from the log
|
||||||
|
// without flooding it with the frequent streamed messages.
|
||||||
|
if t.unhandledSeen == nil {
|
||||||
|
t.unhandledSeen = map[string]bool{}
|
||||||
|
}
|
||||||
|
if !t.unhandledSeen[lname] {
|
||||||
|
t.unhandledSeen[lname] = true
|
||||||
|
debugLog.Printf("TCI: (unhandled once) %s", msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,39 @@ func (m *Manager) EntityNames() []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrefixByDXCC maps ADIF DXCC entity number → canonical primary prefix (F, DL,
|
||||||
|
// XE…) from the loaded cty.dat. cty.dat is keyed by entity NAME, so the join
|
||||||
|
// goes through EntityDXCC; entries whose name doesn't resolve are skipped
|
||||||
|
// rather than guessed. Empty until cty.dat has loaded.
|
||||||
|
func (m *Manager) PrefixByDXCC() map[int]string {
|
||||||
|
m.mu.RLock()
|
||||||
|
db := m.db
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[int]string, 400)
|
||||||
|
for _, e := range db.Entities() {
|
||||||
|
p := strings.TrimSpace(e.Primary)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// cty.dat marks non-DXCC sub-entities with a leading '*' — they report
|
||||||
|
// under a parent entity and must not overwrite the parent's prefix.
|
||||||
|
if strings.HasPrefix(p, "*") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n := EntityDXCC(e.Name); n > 0 {
|
||||||
|
if _, dup := out[n]; !dup {
|
||||||
|
// DXCC prefixes are conventionally upper-case; cty.dat spells some
|
||||||
|
// sub-entity suffixes in lower case (3D2/c → 3D2/C).
|
||||||
|
out[n] = strings.ToUpper(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Info returns metadata about the currently-loaded cty.dat (or zero value
|
// Info returns metadata about the currently-loaded cty.dat (or zero value
|
||||||
// if nothing loaded).
|
// if nothing loaded).
|
||||||
func (m *Manager) Info() ctySource {
|
func (m *Manager) Info() ctySource {
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ const (
|
|||||||
defaultPort = 9008
|
defaultPort = 9008
|
||||||
dialTimeout = 5 * time.Second
|
dialTimeout = 5 * time.Second
|
||||||
ioTimeout = 3 * time.Second
|
ioTimeout = 3 * time.Second
|
||||||
pollEvery = 1500 * time.Millisecond
|
// Poll fast enough that the amp's OWN forward/current figures make a usable
|
||||||
|
// live meter on their own — the UI prefers them over the FlexRadio VITA stream
|
||||||
|
// (which never traverses a public-IP/NAT link), so this direct reading is what
|
||||||
|
// an operator watches when running the amp over the internet. At 250 ms the
|
||||||
|
// SSB envelope is sampled often enough that peak-hold keeps a steady reading
|
||||||
|
// instead of collapsing to ~1 W in the gaps between syllables.
|
||||||
|
pollEvery = 250 * time.Millisecond
|
||||||
reconnectDelay = 2 * time.Second
|
reconnectDelay = 2 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,6 +58,7 @@ type Status struct {
|
|||||||
type Client struct {
|
type Client struct {
|
||||||
host string
|
host string
|
||||||
port int
|
port int
|
||||||
|
password string // remote-access code; sent as "auth <code>" when the banner announces AUTH
|
||||||
|
|
||||||
mu sync.Mutex // serialises command send/recv on the connection
|
mu sync.Mutex // serialises command send/recv on the connection
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
@@ -70,11 +77,11 @@ type Client struct {
|
|||||||
running bool
|
running bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(host string, port int) *Client {
|
func New(host string, port int, password string) *Client {
|
||||||
if port <= 0 || port > 65535 {
|
if port <= 0 || port > 65535 {
|
||||||
port = defaultPort
|
port = defaultPort
|
||||||
}
|
}
|
||||||
return &Client{host: host, port: port, stop: make(chan struct{}), status: Status{Host: host}}
|
return &Client{host: host, port: port, password: strings.TrimSpace(password), stop: make(chan struct{}), status: Status{Host: host}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Start() error {
|
func (c *Client) Start() error {
|
||||||
@@ -118,9 +125,15 @@ func (c *Client) SetFanMode(mode string) error {
|
|||||||
default:
|
default:
|
||||||
return fmt.Errorf("powergenius: invalid fan mode %q", mode)
|
return fmt.Errorf("powergenius: invalid fan mode %q", mode)
|
||||||
}
|
}
|
||||||
if _, err := c.command("setup fanmode=" + m); err != nil {
|
// Set with the bare "key=value" verb the amp uses for its own status fields
|
||||||
|
// (same convention as "operate=1"). The earlier "setup fanmode=…" carried a
|
||||||
|
// bogus prefix the amp silently ignored, so the fan never changed and the next
|
||||||
|
// status kept reporting the old mode — the revert-to-Contest the operator saw.
|
||||||
|
reply, err := c.command("fanmode=" + m)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
applog.Printf("pgxl: set fanmode=%s reply=%q", m, reply)
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.status.FanMode = m // optimistic
|
c.status.FanMode = m // optimistic
|
||||||
c.fanPending, c.fanPendingAt = m, time.Now()
|
c.fanPending, c.fanPendingAt = m, time.Now()
|
||||||
@@ -177,12 +190,62 @@ func (c *Client) ensureConnected() error {
|
|||||||
}
|
}
|
||||||
c.conn = conn
|
c.conn = conn
|
||||||
c.reader = bufio.NewReader(conn)
|
c.reader = bufio.NewReader(conn)
|
||||||
// Discard the version banner the device sends on connect.
|
// Banner: "V…" (LAN) or "V… AUTH" (remote → authentication required, exactly
|
||||||
|
// like the Tuner Genius / Antenna Genius). Send the remote code when the amp
|
||||||
|
// demands it, otherwise every command comes back "Unauthorized".
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||||
_, _ = c.reader.ReadString('\n')
|
banner, _ := c.reader.ReadString('\n')
|
||||||
|
banner = strings.TrimSpace(banner)
|
||||||
|
applog.Printf("pgxl: connected %s → %s, banner=%q", conn.LocalAddr(), conn.RemoteAddr(), banner)
|
||||||
|
if strings.Contains(banner, "AUTH") {
|
||||||
|
if c.password == "" {
|
||||||
|
applog.Printf("pgxl: device requires AUTH but no remote code set (Settings → Amplifier)")
|
||||||
|
} else if err := c.authLocked(); err != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn, c.reader = nil, nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authLocked authenticates the remote link. Must be called with c.mu held
|
||||||
|
// (during ensureConnected). 4O3A boxes want "auth code=<pw>" and reply
|
||||||
|
// "R<seq>|<hex>|" with an EMPTY message — hex "0" means accepted, so the response
|
||||||
|
// CODE decides, not the text. The device also rejects the FIRST attempt (R|FF)
|
||||||
|
// and accepts a retry, so resend a few times before giving up.
|
||||||
|
func (c *Client) authLocked() error {
|
||||||
|
var lastHex string
|
||||||
|
for try := 1; try <= 4; try++ {
|
||||||
|
id := c.cmdID.Add(1)
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||||
|
if _, err := fmt.Fprintf(c.conn, "C%d|auth code=%s\n", id, c.password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||||
|
line, err := c.reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
applog.Printf("pgxl: auth reply=%q (try %d)", line, try)
|
||||||
|
hex, msg := "", ""
|
||||||
|
if p := strings.SplitN(line, "|", 3); len(p) >= 2 {
|
||||||
|
hex = strings.TrimSpace(p[1])
|
||||||
|
if len(p) == 3 {
|
||||||
|
msg = strings.TrimSpace(p[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// hex "0" is the standard accept; also treat an explicit OK message as success.
|
||||||
|
if hex == "0" || (msg != "" && strings.Contains(strings.ToLower(msg), "ok")) {
|
||||||
|
applog.Printf("pgxl: authenticated")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastHex = hex
|
||||||
|
}
|
||||||
|
return fmt.Errorf("powergenius: authentication failed after 4 tries (R|%s|) — check the remote code", lastHex)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) dropConn() {
|
func (c *Client) dropConn() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
@@ -237,9 +300,10 @@ func (c *Client) parse(resp string) {
|
|||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.status.Connected = true
|
c.status.Connected = true
|
||||||
c.status.LastError = ""
|
c.status.LastError = ""
|
||||||
// One raw frame per session is enough to learn the field set — the frames
|
// Log the first REAL status frame (one that carries "key=value" fields) so the
|
||||||
// carry live meter values, so "log on change" logged every frame.
|
// field set is visible — even if an earlier reply was junk like "Unauthorized"
|
||||||
if c.lastRaw == "" {
|
// (which would otherwise latch lastRaw and hide the real frame).
|
||||||
|
if strings.Contains(data, "=") && !strings.Contains(c.lastRaw, "=") {
|
||||||
c.lastRaw = data
|
c.lastRaw = data
|
||||||
applog.Printf("pgxl: status raw=%q", data)
|
applog.Printf("pgxl: status raw=%q", data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1461,6 +1461,51 @@ func (r *Repo) IterateByIDs(ctx context.Context, ids []int64, fn func(QSO) error
|
|||||||
return rows.Err()
|
return rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BandSlotQSOs returns every contact on one band that belongs to a slot of the
|
||||||
|
// entry matrix: the exact callsign, or any callsign in the same DXCC entity.
|
||||||
|
// Mode is NOT filtered here — the class (phone / CW / digital) is a derived
|
||||||
|
// notion the caller owns, so it filters the (small) band-scoped result itself.
|
||||||
|
//
|
||||||
|
// The DXCC arm matches the stored dxcc column only. A QSO logged before the
|
||||||
|
// entity was resolved has a NULL there and is genuinely unattributed; guessing
|
||||||
|
// it back from the callsign here would disagree with the matrix above, which is
|
||||||
|
// built from the same column.
|
||||||
|
func (r *Repo) BandSlotQSOs(ctx context.Context, callsign string, dxccNum int, band string) ([]QSO, error) {
|
||||||
|
call := upperTrim(callsign)
|
||||||
|
b := strings.TrimSpace(band)
|
||||||
|
if b == "" || (call == "" && dxccNum <= 0) {
|
||||||
|
return []QSO{}, nil
|
||||||
|
}
|
||||||
|
where := "band = ?"
|
||||||
|
args := []any{b}
|
||||||
|
switch {
|
||||||
|
case call != "" && dxccNum > 0:
|
||||||
|
where += " AND (callsign = ? OR dxcc = ?)"
|
||||||
|
args = append(args, call, dxccNum)
|
||||||
|
case call != "":
|
||||||
|
where += " AND callsign = ?"
|
||||||
|
args = append(args, call)
|
||||||
|
default:
|
||||||
|
where += " AND dxcc = ?"
|
||||||
|
args = append(args, dxccNum)
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT `+selectCols+` FROM qso WHERE `+where+` ORDER BY qso_date DESC, id DESC LIMIT 500`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query band slot: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []QSO{}
|
||||||
|
for rows.Next() {
|
||||||
|
q, err := scanQSO(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// WorkedBefore summarises prior contacts at two granularities:
|
// WorkedBefore summarises prior contacts at two granularities:
|
||||||
// - by exact callsign → shown as "this call worked N×"
|
// - by exact callsign → shown as "this call worked N×"
|
||||||
// - by DXCC entity → drives NEW ONE / NEW BAND / NEW MODE / NEW SLOT
|
// - by DXCC entity → drives NEW ONE / NEW BAND / NEW MODE / NEW SLOT
|
||||||
@@ -2118,6 +2163,36 @@ func (r *Repo) WorkedCallBandModeKeys(ctx context.Context) (map[string]struct{},
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WorkedCallSlotKeys backs the cluster "already worked only on the same slot"
|
||||||
|
// option. It returns each worked slot keyed BOTH as "CALL|band" (mode-agnostic,
|
||||||
|
// for a spot whose mode couldn't be inferred) and "CALL|band|MODE" with the mode
|
||||||
|
// run through normMode — so when digital grouping is on, FT8/FT4/RTTY fold into
|
||||||
|
// one "DIG" and a call worked on 20m FT8 also matches a 20m FT4 spot. Call is
|
||||||
|
// upper-cased and band lower-cased to match the spot keys ClusterSpotStatuses
|
||||||
|
// builds. normMode may be nil (no grouping).
|
||||||
|
func (r *Repo) WorkedCallSlotKeys(ctx context.Context, normMode func(string) string) (map[string]struct{}, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT upper(callsign), lower(band), upper(mode) FROM qso
|
||||||
|
WHERE callsign != '' AND band IS NOT NULL AND band != '' AND mode IS NOT NULL AND mode != ''`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[string]struct{}, 4096)
|
||||||
|
for rows.Next() {
|
||||||
|
var c, b, m string
|
||||||
|
if err := rows.Scan(&c, &b, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if normMode != nil {
|
||||||
|
m = normMode(m)
|
||||||
|
}
|
||||||
|
out[c+"|"+b] = struct{}{}
|
||||||
|
out[c+"|"+b+"|"+m] = struct{}{}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// WorkedPOTARefs returns the set of POTA park references already worked
|
// WorkedPOTARefs returns the set of POTA park references already worked
|
||||||
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks
|
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks
|
||||||
// (an n-fer); each is added separately.
|
// (an n-fer); each is added separately.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,6 +60,13 @@ type Server struct {
|
|||||||
ln net.Listener
|
ln net.Listener
|
||||||
conns map[net.Conn]struct{}
|
conns map[net.Conn]struct{}
|
||||||
closed bool
|
closed bool
|
||||||
|
|
||||||
|
// ptt mirrors the last PTT state a client commanded via set_ptt. WSJT-X/JTDX
|
||||||
|
// poll get_ptt DURING transmit to confirm the rig is keyed; if get_ptt reads
|
||||||
|
// RX they conclude PTT failed and abort the over after a second or two. We
|
||||||
|
// don't read PTT back from every backend, so echo what the client last set —
|
||||||
|
// always consistent with its own command, and enough to satisfy the check.
|
||||||
|
ptt atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(port int, rig Rig, logf func(string, ...any)) *Server {
|
func New(port int, rig Rig, logf func(string, ...any)) *Server {
|
||||||
@@ -228,10 +236,12 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
|||||||
return rprt(0), false
|
return rprt(0), false
|
||||||
|
|
||||||
case "t", "\\get_ptt":
|
case "t", "\\get_ptt":
|
||||||
// We do not read PTT back from every backend, and answering "transmitting"
|
// Echo the last commanded PTT state. WSJT-X/JTDX poll this WHILE
|
||||||
// wrongly would make a client hold off for ever. Reporting RX is the safe
|
// transmitting to confirm the rig is keyed; answering a blanket "0" (RX)
|
||||||
// direction: the worst case is a client that transmits when we said it
|
// made them decide PTT had failed and abort the over after ~1-2 s.
|
||||||
// could, which is what it was going to do anyway.
|
if s.ptt.Load() {
|
||||||
|
return "1\n", false
|
||||||
|
}
|
||||||
return "0\n", false
|
return "0\n", false
|
||||||
case "T", "\\set_ptt":
|
case "T", "\\set_ptt":
|
||||||
if len(args) < 1 {
|
if len(args) < 1 {
|
||||||
@@ -242,6 +252,7 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
|||||||
s.log("rigctld: set_ptt %v failed: %v", on, err)
|
s.log("rigctld: set_ptt %v failed: %v", on, err)
|
||||||
return rprt(-9), false
|
return rprt(-9), false
|
||||||
}
|
}
|
||||||
|
s.ptt.Store(on)
|
||||||
return rprt(0), false
|
return rprt(0), false
|
||||||
|
|
||||||
case "v", "\\get_vfo":
|
case "v", "\\get_vfo":
|
||||||
|
|||||||
@@ -138,6 +138,28 @@ func TestHandleReportsBackendFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// get_ptt must echo the last commanded PTT state. WSJT-X/JTDX poll get_ptt while
|
||||||
|
// transmitting to confirm the rig is keyed; a blanket "0" made them decide PTT
|
||||||
|
// had failed and abort the over after a second or two.
|
||||||
|
func TestGetPTTEchoesSetPTT(t *testing.T) {
|
||||||
|
s := New(0, &fakeRig{}, nil)
|
||||||
|
if got, _ := s.handle("t"); got != "0\n" {
|
||||||
|
t.Fatalf("initial get_ptt = %q, want 0", got)
|
||||||
|
}
|
||||||
|
if got, _ := s.handle("T 1"); got != "RPRT 0\n" {
|
||||||
|
t.Fatalf("set_ptt 1 = %q, want RPRT 0", got)
|
||||||
|
}
|
||||||
|
if got, _ := s.handle("t"); got != "1\n" {
|
||||||
|
t.Fatalf("get_ptt after T 1 = %q, want 1 — client would abort TX", got)
|
||||||
|
}
|
||||||
|
if got, _ := s.handle("T 0"); got != "RPRT 0\n" {
|
||||||
|
t.Fatalf("set_ptt 0 = %q, want RPRT 0", got)
|
||||||
|
}
|
||||||
|
if got, _ := s.handle("t"); got != "0\n" {
|
||||||
|
t.Fatalf("get_ptt after T 0 = %q, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// dump_state is parsed POSITIONALLY by Hamlib clients: WSJT-X reads the first
|
// dump_state is parsed POSITIONALLY by Hamlib clients: WSJT-X reads the first
|
||||||
// line as the protocol version and refuses to continue if the block is short or
|
// line as the protocol version and refuses to continue if the block is short or
|
||||||
// misshapen. Pinning its shape is what stops a well-meaning edit from silently
|
// misshapen. Pinning its shape is what stops a well-meaning edit from silently
|
||||||
|
|||||||
@@ -228,6 +228,18 @@ func (c *Client) PowerOn() error {
|
|||||||
func (c *Client) PowerOff() error {
|
func (c *Client) PowerOff() error {
|
||||||
err := c.sendCmd(cmdOff)
|
err := c.sendCmd(cmdOff)
|
||||||
applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err)
|
applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err)
|
||||||
|
// Drop the link and mark the amp offline at once, exactly as a fresh start
|
||||||
|
// would see it. The amp stops answering its UART once off, but the connection
|
||||||
|
// that was already open BEFORE the off keystroke kept reading as "connected"
|
||||||
|
// (stale/queued frames), so the UI never showed OFF and PowerOn — which only
|
||||||
|
// wakes a DISCONNECTED amp — did nothing until the operator restarted OpsLog.
|
||||||
|
// The poll loop reopens the port on its next tick; a plain reopen never wakes a
|
||||||
|
// switched-off amp (only PowerOn's RTS/DTR edge does), so it stays offline.
|
||||||
|
time.Sleep(100 * time.Millisecond) // let the 6-byte OFF command flush before we close
|
||||||
|
c.mu.Lock()
|
||||||
|
c.dropLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.setErr(fmt.Errorf("switched off"))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+113
-36
@@ -23,6 +23,12 @@ const (
|
|||||||
ubReadTimeout = 4 * time.Second // was 1s — too tight for a remote link
|
ubReadTimeout = 4 * time.Second // was 1s — too tight for a remote link
|
||||||
ubKeepAlive = 15 * time.Second // OS-level TCP keepalive
|
ubKeepAlive = 15 * time.Second // OS-level TCP keepalive
|
||||||
ubMaxPollTimeout = 3 // consecutive read timeouts tolerated before reconnecting
|
ubMaxPollTimeout = 3 // consecutive read timeouts tolerated before reconnecting
|
||||||
|
// How long a just-commanded direction is trusted AFTER the motors have stopped
|
||||||
|
// but before the antenna's status confirms it. The timer is held off entirely
|
||||||
|
// while the motors are still moving, so this is only the grace period for the
|
||||||
|
// confirmation poll to arrive once the elements have settled — generous, because
|
||||||
|
// over a remote link that poll lags by several seconds.
|
||||||
|
ubPendingDirGrace = 8 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Protocol constants
|
// Protocol constants
|
||||||
@@ -82,8 +88,22 @@ type Client struct {
|
|||||||
// yet (Frequency==0) — otherwise the deadband is bypassed and every small QSY
|
// yet (Frequency==0) — otherwise the deadband is bypassed and every small QSY
|
||||||
// re-tunes the motors.
|
// re-tunes the motors.
|
||||||
lastSetKHz int
|
lastSetKHz int
|
||||||
|
|
||||||
|
// moveCmdAt is when a move (frequency or direction) was last COMMANDED. The
|
||||||
|
// status poll runs every couple of seconds, so without this the "moving" flag
|
||||||
|
// — which drives the UI indicator and the Flex TX-inhibit — appeared up to a
|
||||||
|
// poll late even though the elements start moving at once. GetStatus reports
|
||||||
|
// motion during a short window after a command so both react immediately.
|
||||||
|
moveCmdAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ubMoveOptimisticWindow is how long after a commanded move GetStatus reports
|
||||||
|
// "moving" before the status poll has had a chance to read the real motor state.
|
||||||
|
// It only needs to bridge one poll interval; once a poll sees real motion, that
|
||||||
|
// takes over. Bounded, so if the antenna never reports motion the flag still
|
||||||
|
// clears rather than latching the TX-inhibit on for ever.
|
||||||
|
const ubMoveOptimisticWindow = 3 * time.Second
|
||||||
|
|
||||||
// LastSetKHz returns the frequency (kHz) most recently commanded to the antenna,
|
// LastSetKHz returns the frequency (kHz) most recently commanded to the antenna,
|
||||||
// or 0 if none yet.
|
// or 0 if none yet.
|
||||||
func (c *Client) LastSetKHz() int {
|
func (c *Client) LastSetKHz() int {
|
||||||
@@ -221,12 +241,30 @@ func (c *Client) pollLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
// Keep a just-commanded direction until the antenna reports it.
|
// Keep a just-commanded direction until the antenna actually reports it.
|
||||||
|
// Over a remote link the confirmation arrives several seconds after the
|
||||||
|
// command — the motors flip the elements first — so the old fixed 4 s
|
||||||
|
// timeout expired WHILE the change was still in flight, and the stale poll
|
||||||
|
// reverted the UI to the old pattern even though the antenna was on its way
|
||||||
|
// to the new one. Now: while the motors are still moving the change is in
|
||||||
|
// progress, so hold the commanded pattern and keep resetting the timer;
|
||||||
|
// only once the motors have stopped does the short grace window run, giving
|
||||||
|
// the confirmation poll time to land. The poll only wins if the motors are
|
||||||
|
// idle AND the antenna still reports a different pattern past that window —
|
||||||
|
// i.e. the command genuinely did not take.
|
||||||
if c.pendingDirSet {
|
if c.pendingDirSet {
|
||||||
if time.Since(c.pendingDirAt) > 4*time.Second || status.Direction == c.pendingDir {
|
if status.MotorsMoving != 0 {
|
||||||
c.pendingDirSet = false
|
c.pendingDirAt = time.Now() // still repositioning — don't start the grace timer
|
||||||
} else {
|
}
|
||||||
status.Direction = c.pendingDir
|
switch {
|
||||||
|
case status.Direction == c.pendingDir:
|
||||||
|
c.pendingDirSet = false // confirmed by the antenna
|
||||||
|
case time.Since(c.pendingDirAt) > ubPendingDirGrace:
|
||||||
|
c.pendingDirSet = false // motors idle, still unconfirmed → accept the poll
|
||||||
|
log.Printf("Ultrabeam: antenna never confirmed direction %d (reports %d) — dropping the hold",
|
||||||
|
c.pendingDir, status.Direction)
|
||||||
|
default:
|
||||||
|
status.Direction = c.pendingDir // still changing, or within the grace window
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.lastStatus = status
|
c.lastStatus = status
|
||||||
@@ -246,7 +284,19 @@ func (c *Client) GetStatus() (*Status, error) {
|
|||||||
return &Status{Connected: false}, nil
|
return &Status{Connected: false}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.lastStatus, nil
|
// Copy so the optimistic-motion tweak below never mutates the cached status
|
||||||
|
// the poll goroutine owns.
|
||||||
|
st := *c.lastStatus
|
||||||
|
// Optimistic motion: right after a commanded move, report "moving" until a
|
||||||
|
// status poll can read the real motor state (~one poll interval). The elements
|
||||||
|
// start moving the instant the operator clicks a band/pattern, so this makes
|
||||||
|
// the UI indicator and the Flex TX-inhibit react immediately instead of a poll
|
||||||
|
// later. Real polled motion takes over once seen; the window is bounded so the
|
||||||
|
// flag can't latch the inhibit on for ever.
|
||||||
|
if st.Connected && st.MotorsMoving == 0 && !c.moveCmdAt.IsZero() && time.Since(c.moveCmdAt) < ubMoveOptimisticWindow {
|
||||||
|
st.MotorsMoving = 1 // sentinel: optimistically moving (read only as != 0)
|
||||||
|
}
|
||||||
|
return &st, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getNextSeq returns the next sequence number
|
// getNextSeq returns the next sequence number
|
||||||
@@ -277,10 +327,9 @@ func quoteByte(b byte) []byte {
|
|||||||
return []byte{b}
|
return []byte{b}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildPacket creates a complete packet with checksum and escaping
|
// buildPacket creates a complete packet with checksum and escaping. The seq is
|
||||||
func (c *Client) buildPacket(cmd byte, data []byte) []byte {
|
// supplied by the caller so sendCommand can match the reply against it.
|
||||||
seq := c.getNextSeq()
|
func (c *Client) buildPacket(seq, cmd byte, data []byte) []byte {
|
||||||
|
|
||||||
// Calculate checksum on unquoted data
|
// Calculate checksum on unquoted data
|
||||||
payload := append([]byte{seq, cmd}, data...)
|
payload := append([]byte{seq, cmd}, data...)
|
||||||
chk := calculateChecksum(payload)
|
chk := calculateChecksum(payload)
|
||||||
@@ -365,39 +414,27 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("not connected")
|
return nil, fmt.Errorf("not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build and send packet
|
// Flush anything already sitting in the stream before we send. The antenna
|
||||||
packet := c.buildPacket(cmd, data)
|
// does NOT echo our sequence number — its replies carry their own counter — so
|
||||||
|
// a reply cannot be matched to its request. Instead we keep the exchange 1:1:
|
||||||
|
// a reply left behind by an earlier timed-out command is discarded here, so
|
||||||
|
// the reply we read next belongs to THIS command. Reading a stale reply as the
|
||||||
|
// current one crossed STATUS with READ_BANDS/PROGRESS — phantom frequencies (a
|
||||||
|
// spurious follow-loop re-tune), dropped connections and wrong element lengths.
|
||||||
|
c.drainStale()
|
||||||
|
|
||||||
_, err := c.conn.Write(packet)
|
seq := c.getNextSeq()
|
||||||
if err != nil {
|
packet := c.buildPacket(seq, cmd, data)
|
||||||
|
if _, err := c.conn.Write(packet); err != nil {
|
||||||
return nil, fmt.Errorf("failed to write: %w", err)
|
return nil, fmt.Errorf("failed to write: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read reply with timeout (generous — tolerates remote-link latency).
|
// Read the reply with a timeout generous enough for a remote link.
|
||||||
c.conn.SetReadDeadline(time.Now().Add(ubReadTimeout))
|
c.conn.SetReadDeadline(time.Now().Add(ubReadTimeout))
|
||||||
|
buffer, err := c.readPacket()
|
||||||
// Read until we get a complete packet
|
|
||||||
var buffer []byte
|
|
||||||
for {
|
|
||||||
b, err := c.reader.ReadByte()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer = append(buffer, b)
|
|
||||||
|
|
||||||
// Check if we have a complete packet
|
|
||||||
if b == ETX && len(buffer) > 0 && buffer[0] == STX {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent infinite loop
|
|
||||||
if len(buffer) > 256 {
|
|
||||||
return nil, fmt.Errorf("packet too long")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse reply
|
|
||||||
_, replyCmd, payload, err := parsePacket(buffer)
|
_, replyCmd, payload, err := parsePacket(buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse reply: %w", err)
|
return nil, fmt.Errorf("failed to parse reply: %w", err)
|
||||||
@@ -426,6 +463,45 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drainStale discards any bytes already waiting in the stream — a reply left
|
||||||
|
// behind by a command that timed out. A short read deadline lets it consume what
|
||||||
|
// is there and stop quickly when the stream is clean. Caller holds connMu.
|
||||||
|
func (c *Client) drainStale() {
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(5 * time.Millisecond))
|
||||||
|
buf := make([]byte, 256)
|
||||||
|
for {
|
||||||
|
n, err := c.reader.Read(buf)
|
||||||
|
if n == 0 || err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPacket reads one complete STX…ETX frame, skipping any leading bytes until
|
||||||
|
// an STX so a partial/garbage remnant left in the stream can't derail the parse.
|
||||||
|
// STX/ETX/DLE are all high-bit (0xF5/0xFA/0xF6) and the escaping clears the MSB,
|
||||||
|
// so a raw ETX only ever appears as the real terminator. Caller holds connMu and
|
||||||
|
// has set a read deadline.
|
||||||
|
func (c *Client) readPacket() ([]byte, error) {
|
||||||
|
var buffer []byte
|
||||||
|
for {
|
||||||
|
b, err := c.reader.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read: %w", err)
|
||||||
|
}
|
||||||
|
if len(buffer) == 0 && b != STX {
|
||||||
|
continue // resync to the start of a frame
|
||||||
|
}
|
||||||
|
buffer = append(buffer, b)
|
||||||
|
if b == ETX {
|
||||||
|
return buffer, nil
|
||||||
|
}
|
||||||
|
if len(buffer) > 256 {
|
||||||
|
return nil, fmt.Errorf("packet too long")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// queryStatus queries general status (command 1)
|
// queryStatus queries general status (command 1)
|
||||||
func (c *Client) queryStatus() (*Status, error) {
|
func (c *Client) queryStatus() (*Status, error) {
|
||||||
reply, err := c.sendCommand(CMD_STATUS, nil)
|
reply, err := c.sendCommand(CMD_STATUS, nil)
|
||||||
@@ -513,6 +589,7 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
|||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
|
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
|
||||||
c.lastSetKHz = freqKhz
|
c.lastSetKHz = freqKhz
|
||||||
|
c.moveCmdAt = time.Now() // start reporting motion at once — see ubMoveOptimisticWindow
|
||||||
if c.lastStatus != nil {
|
if c.lastStatus != nil {
|
||||||
c.lastStatus.Direction = direction // reflect immediately
|
c.lastStatus.Direction = direction // reflect immediately
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package ultrabeam
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sendCommand must flush any bytes already in the stream (a reply left by an
|
||||||
|
// earlier timed-out command) before reading, then return the reply to THIS
|
||||||
|
// command. The antenna does not echo our sequence number, so keeping the stream
|
||||||
|
// clean is the only way to stay in sync.
|
||||||
|
func TestSendCommandFlushesStaleThenReadsReply(t *testing.T) {
|
||||||
|
srvConn, cliConn := net.Pipe()
|
||||||
|
defer srvConn.Close()
|
||||||
|
defer cliConn.Close()
|
||||||
|
|
||||||
|
c := &Client{conn: cliConn, reader: bufio.NewReader(cliConn)}
|
||||||
|
srv := &Client{reader: bufio.NewReader(srvConn)}
|
||||||
|
|
||||||
|
errc := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
// Leftover from a "previous" command still sitting in the stream. The Write
|
||||||
|
// blocks until drainStale consumes it, so it is guaranteed flushed before
|
||||||
|
// the real exchange.
|
||||||
|
if _, err := srvConn.Write(c.buildPacket(9, UB_OK, []byte{0xDE, 0xAD})); err != nil {
|
||||||
|
errc <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Now serve the actual request.
|
||||||
|
req, err := srv.readPacket()
|
||||||
|
if err != nil {
|
||||||
|
errc <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seq, _, _, err := parsePacket(req)
|
||||||
|
if err != nil {
|
||||||
|
errc <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = srvConn.Write(c.buildPacket(seq, UB_OK, []byte{0x11, 0x22, 0x33}))
|
||||||
|
errc <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
payload, err := c.sendCommand(CMD_STATUS, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sendCommand: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(payload, []byte{0x11, 0x22, 0x33}) {
|
||||||
|
t.Fatalf("payload = % X, want 11 22 33 — stale reply not flushed", payload)
|
||||||
|
}
|
||||||
|
if err := <-errc; err != nil {
|
||||||
|
t.Fatalf("server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPacket must resynchronise to the next STX, dropping any partial/garbage
|
||||||
|
// bytes left in the stream, so one corrupt frame can't misalign every frame
|
||||||
|
// after it.
|
||||||
|
func TestReadPacketResyncsToSTX(t *testing.T) {
|
||||||
|
frame := (&Client{}).buildPacket(5, UB_OK, []byte{0x01, 0x02})
|
||||||
|
stream := append([]byte{0x11, 0x22, 0x33}, frame...) // leading garbage, then a real frame
|
||||||
|
c := &Client{reader: bufio.NewReader(bytes.NewReader(stream))}
|
||||||
|
|
||||||
|
got, err := c.readPacket()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("readPacket: %v", err)
|
||||||
|
}
|
||||||
|
seq, cmd, payload, err := parsePacket(got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsePacket: %v", err)
|
||||||
|
}
|
||||||
|
if seq != 5 || cmd != UB_OK || !bytes.Equal(payload, []byte{0x01, 0x02}) {
|
||||||
|
t.Fatalf("seq=%d cmd=%d payload=% X, want 5 / OK / 01 02", seq, cmd, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A commanded move must report motion immediately (before the next status poll),
|
||||||
|
// then fall back to the real motor state once the window elapses.
|
||||||
|
func TestOptimisticMotionAfterCommand(t *testing.T) {
|
||||||
|
c := &Client{lastStatus: &Status{Connected: true, MotorsMoving: 0}}
|
||||||
|
|
||||||
|
c.moveCmdAt = time.Now()
|
||||||
|
if st, _ := c.GetStatus(); st.MotorsMoving == 0 {
|
||||||
|
t.Fatal("just after a move command, GetStatus should report motion")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.moveCmdAt = time.Now().Add(-ubMoveOptimisticWindow - time.Second)
|
||||||
|
if st, _ := c.GetStatus(); st.MotorsMoving != 0 {
|
||||||
|
t.Fatal("past the window with motors idle, GetStatus must report no motion")
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.23.2"
|
appVersion = "0.23.5"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user