Compare commits
32
Commits
v0.22.9
...
d0f25aea9b
| 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 | ||
|
|
9846354bf9 | ||
|
|
3da1d71323 | ||
|
|
72f692aa04 |
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
@@ -1,4 +1,138 @@
|
||||
[
|
||||
{
|
||||
"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",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Lookup: the locator now keeps whichever is more precise. A 4-character square from QRZ or HamQTH no longer overwrites the full 6-character locator confirmed on a previous contact.",
|
||||
"LoTW badge: when ARRL's own user list has not been rebuilt for a while, the tooltip says so with its date — a station shown as \"uploaded 7 days ago\" may simply be sitting in a frozen list.",
|
||||
"FlexRadio decode spots: with two WSJT-X instances on two slices, decodes are no longer placed on the wrong panadapter, and a station heard on both is now spotted on both.",
|
||||
"DX cluster: a spot glued to the login prompt is no longer dropped. Clusters that print the prompt without a newline cost you the first spot after connecting.",
|
||||
"Help → Diagnostic log: read the log live, without leaving OpsLog. Filter the lines, and follow the end or scroll back freely.",
|
||||
"Entity stats: the DX station's sunrise and sunset (UTC) now show on the right, next to Most Wanted. Midnight sun and polar night are named as such."
|
||||
],
|
||||
"fr": [
|
||||
"Recherche : le locator garde désormais le plus précis des deux. Un carré en 4 caractères venant de QRZ ou HamQTH n'écrase plus le locator complet en 6 caractères confirmé lors d'un contact précédent.",
|
||||
"Badge LoTW : quand la liste ARRL elle-même n'a pas été régénérée depuis un moment, l'infobulle le dit avec sa date — une station annoncée « dernier envoi il y a 7 jours » peut simplement figurer dans une liste figée.",
|
||||
"Spots de décodes FlexRadio : avec deux instances WSJT-X sur deux slices, les décodes ne s'affichent plus sur le mauvais panadapter, et une station entendue sur les deux est désormais spottée sur les deux.",
|
||||
"Cluster DX : un spot collé à l'invite de connexion n'est plus perdu. Les clusters qui affichent leur invite sans retour à la ligne coûtaient le premier spot après la connexion.",
|
||||
"Aide → Journal de diagnostic : lire le journal en direct sans quitter OpsLog. Filtrage des lignes, et suivi de la fin qu'on peut relâcher pour remonter.",
|
||||
"Statistiques d'entité : le lever et le coucher du soleil de la station DX (UTC) s'affichent à droite, à côté du Most Wanted. Soleil de minuit et nuit polaire sont nommés."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.23.1",
|
||||
"date": "",
|
||||
"en": [
|
||||
"DX cluster: NEW COUNTY is green, like NEW POTA.",
|
||||
"CW: pressing ESC during a macro no longer turns the report from 599 into 59 when \"ESC clears the callsign too\" is on."
|
||||
],
|
||||
"fr": [
|
||||
"Cluster DX : NOUVEAU COMTÉ passe en vert, comme NOUVEAU POTA.",
|
||||
"CW : appuyer sur Échap pendant une macro ne fait plus passer le report de 599 à 59 quand l'option « Échap efface aussi l'indicatif » est cochée."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.23.0",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Station Control: Dingtian IOT relay boards (2 to 32 relays, LAN or WiFi) can now be added.",
|
||||
"ACOM amplifiers: the card now shows TUNE while the tuner is running, instead of an unknown state.",
|
||||
"DX cluster: the status pills are gone. The call, band and mode turn yellow when that one is new, and a callsign already in the log turns blue.",
|
||||
"Reports: starting OpsLog with the rig already in CW no longer leaves the RST fields on 59 — they now follow the mode you are actually on. (Thanks HB9HBY)",
|
||||
"DX cluster: NEW PFX marks a spot whose CQ WPX prefix has never been worked, in the status column and as a filter."
|
||||
],
|
||||
"fr": [
|
||||
"Contrôle station : les cartes relais Dingtian IOT (2 à 32 relais, LAN ou WiFi) peuvent désormais être ajoutées.",
|
||||
"Amplificateurs ACOM : la carte affiche TUNE pendant l'accord, au lieu d'un état inconnu.",
|
||||
"Cluster DX : les pastilles de statut disparaissent. L'indicatif, la bande et le mode passent en jaune quand celui-ci est nouveau, et un indicatif déjà dans le log passe en bleu.",
|
||||
"Reports : lancer OpsLog avec le poste déjà en CW ne laisse plus les RST à 59 — ils suivent maintenant le mode réellement en cours. (Merci HB9HBY)",
|
||||
"Cluster DX : NOUVEAU PFX signale un spot dont le préfixe CQ WPX n'a jamais été contacté, dans la colonne de statut et en filtre."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.9",
|
||||
"date": "",
|
||||
|
||||
+238
-64
@@ -20,7 +20,7 @@ import {
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
|
||||
GetSecretStatus, UnlockSecrets,
|
||||
RefreshCtyDat, DownloadAllReferenceLists,
|
||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||
RotatorGoTo, RotatorStop, GetRotatorHeading, SetActiveRotor,
|
||||
GetDBConnectionInfo, GetLogbookRevision,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
|
||||
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
OpenExternalURL,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||
GetCATSettings,
|
||||
GetCATSettings, PTTHotkeyDown, PTTHotkeyUp,
|
||||
GetSolarData,
|
||||
GetQSORate,
|
||||
LoTWUserInfo,
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
||||
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
||||
IcomSendCW, YaesuSendCW, YaesuStopCW, SetYaesuKeySpeed, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
||||
KenwoodSendCW, KenwoodStopCW, SetKenwoodKeySpeed,
|
||||
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
|
||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||
@@ -120,6 +121,7 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead';
|
||||
import { flagURL } from '@/lib/flags';
|
||||
import { LogViewer } from '@/components/LogViewer';
|
||||
|
||||
type QSO = QSOForm;
|
||||
type ImportResult = adifModels.ImportResult;
|
||||
@@ -492,6 +494,14 @@ export default function App() {
|
||||
const [bandRx, setBandRx] = useState('20m');
|
||||
const [countries, setCountries] = useState<string[]>([]);
|
||||
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
|
||||
// Refs for the report defaults. applyModePreset is called from long-lived
|
||||
// closures — the global key handler is the one that bit us — whose dependency
|
||||
// lists deliberately exclude the mode, so reading the state variables there
|
||||
// gives whatever they were when that closure was built.
|
||||
const modePresetsRef = useRef(modePresets);
|
||||
useEffect(() => { modePresetsRef.current = modePresets; }, [modePresets]);
|
||||
const rstListsRef = useRef(rstLists);
|
||||
useEffect(() => { rstListsRef.current = rstLists; }, [rstLists]);
|
||||
const [rstSent, setRstSent] = useState('59');
|
||||
const [rstRcvd, setRstRcvd] = useState('59');
|
||||
const [grid, setGrid] = useState('');
|
||||
@@ -539,7 +549,7 @@ export default function App() {
|
||||
}, []);
|
||||
// LoTW-user lookup for the entered callsign (from the cached ARRL activity list),
|
||||
// debounced. Drives a colour-coded LoTW badge by last-upload recency.
|
||||
const [lotwInfo, setLotwInfo] = useState<{ is_user: boolean; last_upload?: string; days_ago: number } | null>(null);
|
||||
const [lotwInfo, setLotwInfo] = useState<{ is_user: boolean; last_upload?: string; days_ago: number; feed_date?: string; feed_stale?: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
const c = callsign.trim();
|
||||
if (!c) { setLotwInfo(null); return; }
|
||||
@@ -619,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
|
||||
// in Preferences > Hardware > CAT interface.
|
||||
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
|
||||
// window after manual edits and skip CAT updates during it.
|
||||
const catFreezeUntilRef = useRef<number>(0);
|
||||
@@ -1066,7 +1081,7 @@ export default function App() {
|
||||
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
||||
// auto-call and <LOGQSO> are shared; only the transport differs.
|
||||
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
|
||||
// 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
|
||||
@@ -1079,6 +1094,7 @@ export default function App() {
|
||||
if (src === 'icom') IcomSetKeySpeed(w).catch(() => {});
|
||||
else if (src === 'flex') FlexSetKeySpeed(w).catch(() => {});
|
||||
else if (src === 'yaesu') SetYaesuKeySpeed(w).catch(() => {});
|
||||
else if (src === 'kenwood') SetKenwoodKeySpeed(w).catch(() => {});
|
||||
else WinkeyerSetSpeed(w).catch(() => {});
|
||||
// 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.
|
||||
@@ -1122,6 +1138,7 @@ export default function App() {
|
||||
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
|
||||
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
|
||||
: cwSource === 'yaesu' ? (catState.backend === 'yaesu' && catState.connected)
|
||||
: cwSource === 'kenwood' ? (catState.backend === 'kenwood' && catState.connected)
|
||||
: wkStatus.connected;
|
||||
wkActiveRef.current = wkEnabled && connected;
|
||||
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
|
||||
@@ -1382,7 +1399,7 @@ export default function App() {
|
||||
// county or a new park is not a DXCC state, it is another dimension of the
|
||||
// same spot — which is why the grid shows them as separate badges. Filtering
|
||||
// is an OR across all of them, as it already was for a worked callsign.
|
||||
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county';
|
||||
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county' | 'new-pfx';
|
||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
|
||||
// Mode filter chips. Empty set = show every mode. Categories map the
|
||||
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
||||
@@ -1478,7 +1495,7 @@ export default function App() {
|
||||
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
||||
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
|
||||
// different slots don't share the same colour.
|
||||
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean }>>({});
|
||||
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean; new_pfx?: boolean; pfx?: string }>>({});
|
||||
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
|
||||
// still need resolving without re-subscribing the cluster:spot listener.
|
||||
const spotStatusRef = useRef(spotStatus);
|
||||
@@ -1495,6 +1512,11 @@ export default function App() {
|
||||
const [deletingIds, setDeletingIds] = useState<number[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
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 [bulkEditOpen, setBulkEditOpen] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
@@ -1539,6 +1561,7 @@ export default function App() {
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
// "What's new": the changelog for the version(s) since the operator last ran,
|
||||
// shown once on the first launch after an update (EN/FR per the UI language).
|
||||
const [showLogViewer, setShowLogViewer] = useState(false);
|
||||
const [whatsNew, setWhatsNew] = useState<Array<{ version: string; date: string; en: string[]; fr: string[] }> | null>(null);
|
||||
// Which language the What's-new modal shows — defaults to the UI language but
|
||||
// toggleable, so a French operator running the English UI can still read it.
|
||||
@@ -1754,23 +1777,28 @@ export default function App() {
|
||||
: null
|
||||
), [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[]>(() => {
|
||||
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 === 2) return [rotorAz, (rotorAz + 180) % 360];
|
||||
}
|
||||
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
|
||||
// show where the antenna physically points (boom) vs where it radiates when
|
||||
// the Ultrabeam is reversed/bidirectional.
|
||||
const boomHeading = rotorAz;
|
||||
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';
|
||||
}, [ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
||||
}, [activeRotorMotorized, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
||||
|
||||
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
||||
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
||||
@@ -1917,6 +1945,56 @@ export default function App() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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
|
||||
// rotator is disabled (the backend just reads settings and returns).
|
||||
useEffect(() => {
|
||||
@@ -2100,6 +2178,11 @@ export default function App() {
|
||||
try {
|
||||
const c = await GetCATSettings();
|
||||
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 ?? '');
|
||||
} catch {}
|
||||
}, []);
|
||||
@@ -2112,11 +2195,22 @@ export default function App() {
|
||||
setModePresets(l.modes);
|
||||
const names = l.modes.map((m) => m.name);
|
||||
setModes(names);
|
||||
setMode((cur) => names.includes(cur) ? cur : names[0]);
|
||||
const preset = l.modes.find((m) => m.name === mode) ?? l.modes[0];
|
||||
if (preset && !rstUserEditedRef.current) {
|
||||
if (preset.default_rst_sent) setRstSent(preset.default_rst_sent);
|
||||
if (preset.default_rst_rcvd) setRstRcvd(preset.default_rst_rcvd);
|
||||
// Resolve the mode we are ACTUALLY on, then take that mode's report.
|
||||
//
|
||||
// This used to read the `mode` state variable, which this callback
|
||||
// captures once (its dep list is empty) — so it was still the initial
|
||||
// 'SSB' — and fell back to l.modes[0], SSB again, when the lookup
|
||||
// missed. An operator who launched OpsLog with the rig already in CW
|
||||
// and did not touch the VFO got 59/59 and kept it: nothing else runs
|
||||
// until the CAT state actually changes.
|
||||
const cur = names.includes(modeRef.current) ? modeRef.current : names[0];
|
||||
setMode(cur);
|
||||
if (!rstUserEditedRef.current) {
|
||||
const lists: RSTLists = { phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] };
|
||||
const p = l.modes.find((m) => m.name === cur);
|
||||
const fallback = rstOptions(cur, lists)[0] || '';
|
||||
setRstSent(p?.default_rst_sent || fallback);
|
||||
setRstRcvd(p?.default_rst_rcvd || fallback);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
@@ -2176,8 +2270,9 @@ export default function App() {
|
||||
// Prefer the user's configured preset RST; otherwise fall back to the mode
|
||||
// category default (CW/RTTY/PSK → 599, phone → 59, digital → first option)
|
||||
// so switching SSB→CW flips 59→599 even without a configured preset.
|
||||
const p = modePresets.find((x) => x.name === m);
|
||||
const fallback = rstOptions(m, rstLists)[0] || '';
|
||||
// Read through the refs, never the state: see their declaration.
|
||||
const p = modePresetsRef.current.find((x) => x.name === m);
|
||||
const fallback = rstOptions(m, rstListsRef.current)[0] || '';
|
||||
setRstSent(p?.default_rst_sent || fallback);
|
||||
setRstRcvd(p?.default_rst_rcvd || fallback);
|
||||
}
|
||||
@@ -2450,6 +2545,8 @@ export default function App() {
|
||||
worked_call: !!(r as any).worked_call,
|
||||
new_county: !!(r as any).new_county,
|
||||
new_pota: !!(r as any).new_pota,
|
||||
new_pfx: !!(r as any).new_pfx,
|
||||
pfx: (r as any).pfx,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
@@ -2515,6 +2612,11 @@ export default function App() {
|
||||
const applyUdpCall = (raw: string, force = false): boolean => {
|
||||
const call = String(raw ?? '').trim();
|
||||
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 current = callsignValRef.current.trim().toUpperCase();
|
||||
const prev = lastUdpCallRef.current;
|
||||
@@ -2547,6 +2649,11 @@ export default function App() {
|
||||
const call = String(p?.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 total = Number(p?.total ?? 0);
|
||||
const processed = Number(p?.processed ?? 0);
|
||||
@@ -2581,7 +2688,7 @@ export default function App() {
|
||||
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
||||
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
|
||||
}, []);
|
||||
|
||||
@@ -2609,8 +2716,9 @@ export default function App() {
|
||||
s.engine === 'icom' ? 'icom'
|
||||
: s.engine === 'flex' ? 'flex'
|
||||
: s.engine === 'yaesu' ? 'yaesu'
|
||||
: s.engine === 'serial' ? 'serial'
|
||||
: 'winkeyer');
|
||||
: s.engine === 'kenwood' ? 'kenwood'
|
||||
: s.engine === 'serial' ? 'serial'
|
||||
: 'winkeyer');
|
||||
} catch { /* keyer not configured */ }
|
||||
}, []);
|
||||
|
||||
@@ -2736,7 +2844,7 @@ export default function App() {
|
||||
// segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
|
||||
// variables correctly.
|
||||
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++) {
|
||||
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
|
||||
const resolved = parts[p];
|
||||
@@ -2748,39 +2856,37 @@ export default function App() {
|
||||
// current WPM, so it scales automatically.
|
||||
const keyed = 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)));
|
||||
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
|
||||
// send done before its gap+resend. A segment a <LOGQSO> follows is NOT waited
|
||||
// on: the keyer has already buffered the CW, so logging happens AT ONCE and
|
||||
// never interrupts what's being sent. (Gating the log on a CW-length ESTIMATE
|
||||
// made <LOGQSO> log up to ~10s late when the estimate ran long.)
|
||||
if (isLast) {
|
||||
if (isRig) {
|
||||
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
|
||||
} else {
|
||||
// WinKeyer / serial keyer: the message takes AT LEAST its own length to
|
||||
// send, so the estimate is a floor and the busy signal only extends it.
|
||||
//
|
||||
// Waiting on busy alone made the auto-call gap start early: busy travels
|
||||
// from the keyer to this window as an event, and if it had not arrived
|
||||
// and cleared within the one-second start window the wait simply ended.
|
||||
// A 6 s gap then ran from the START of the CQ — about 2 s of silence
|
||||
// after a 4 s call, which is what an operator hears as "clearly not 6".
|
||||
const estMs = Math.round(estimateCwMs(resolved, wkWpm));
|
||||
const started = Date.now();
|
||||
const capMs = Math.round(estMs * 1.4) + 2500;
|
||||
const deadline = started + capMs;
|
||||
// The message's own duration, first — this is the part that must not be skipped.
|
||||
while (Date.now() - started < estMs && !aborted()) await sleep(50);
|
||||
// Then let a still-sending keyer finish (slow link, long buffer), capped.
|
||||
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
||||
}
|
||||
// WAIT for THIS segment's CW to finish before moving on — so a <LOGQSO>
|
||||
// that follows logs AFTER the text before it (e.g. "TU") has actually been
|
||||
// sent, not the instant it's buffered into the keyer. Buffering then logging
|
||||
// "at once" made a macro ending in "TU <LOGQSO>" log on the very first
|
||||
// letter. Auto-call also needs the send finished before its gap+resend.
|
||||
if (isRig) {
|
||||
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
|
||||
} else {
|
||||
// WinKeyer / serial keyer: the message takes AT LEAST its own length to
|
||||
// send, so the estimate is a floor and the busy signal only extends it.
|
||||
//
|
||||
// Waiting on busy alone made the auto-call gap start early: busy travels
|
||||
// from the keyer to this window as an event, and if it had not arrived
|
||||
// and cleared within the one-second start window the wait simply ended.
|
||||
// A 6 s gap then ran from the START of the CQ — about 2 s of silence
|
||||
// after a 4 s call, which is what an operator hears as "clearly not 6".
|
||||
const estMs = Math.round(estimateCwMs(resolved, wkWpm));
|
||||
const started = Date.now();
|
||||
const capMs = Math.round(estMs * 1.4) + 2500;
|
||||
const deadline = started + capMs;
|
||||
// The message's own duration, first — this is the part that must not be skipped.
|
||||
while (Date.now() - started < estMs && !aborted()) await sleep(50);
|
||||
// Then let a still-sending keyer finish (slow link, long buffer), capped.
|
||||
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -2792,6 +2898,7 @@ export default function App() {
|
||||
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
||||
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
|
||||
else if (cwSourceRef.current === 'kenwood') KenwoodStopCW().catch(() => {});
|
||||
else WinkeyerStop().catch(() => {});
|
||||
}
|
||||
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
|
||||
@@ -2905,6 +3012,8 @@ export default function App() {
|
||||
worked_call: !!(r as any).worked_call,
|
||||
new_county: !!(r as any).new_county,
|
||||
new_pota: !!(r as any).new_pota,
|
||||
new_pfx: !!(r as any).new_pfx,
|
||||
pfx: (r as any).pfx,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
@@ -3013,7 +3122,11 @@ export default function App() {
|
||||
setWb(null); // clear the Worked-before grid for the just-cleared callsign
|
||||
setLookupError('');
|
||||
rstUserEditedRef.current = false;
|
||||
applyModePreset(mode);
|
||||
// modeRef, not `mode`: resetEntry is reached from the global key handler,
|
||||
// whose effect does not re-run on a mode change. Pressing ESC during a CW
|
||||
// macro therefore reset the report using the mode as it was when that
|
||||
// handler was built — 'SSB' — and turned 599 into 59.
|
||||
applyModePreset(modeRef.current);
|
||||
setDetails((d) => ({
|
||||
...d,
|
||||
state: '', cnty: '', address: '', lat: undefined, lon: undefined,
|
||||
@@ -3207,6 +3320,24 @@ export default function App() {
|
||||
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) {
|
||||
setWbBusy(true);
|
||||
try {
|
||||
@@ -3265,10 +3396,16 @@ export default function App() {
|
||||
if (!ue.has('name') && empty(r?.name) && last.name) setName(last.name);
|
||||
if (!ue.has('qth') && empty(r?.qth) && last.qth) setQth(last.qth);
|
||||
if (!ue.has('country') && empty(r?.country) && last.country) setCountry(last.country);
|
||||
// Grid: a REAL provider grid always wins. Otherwise the last QSO's precise
|
||||
// locator beats the coarse cty.dat entity centroid the provider block set — so
|
||||
// a French call resolves to its real JNxx, not the country's JN16 centroid.
|
||||
const adoptLastGrid = !ue.has('grid') && empty(r?.grid) && !!last.grid;
|
||||
// Grid: the provider (QRZ / HamQTH) leads, but only while it is at least as
|
||||
// PRECISE as what we already worked. It used to lead whenever it answered at
|
||||
// all, so a 4-character square — which is what a provider returns when the
|
||||
// station only published a rough locator, and what the cty.dat entity
|
||||
// centroid gives — overwrote the full 6-character locator confirmed on a
|
||||
// previous contact. Comparing lengths keeps the finer of the two, whichever
|
||||
// side it came from.
|
||||
const provGrid = String(r?.grid ?? '').trim();
|
||||
const lastGrid = String(last.grid ?? '').trim();
|
||||
const adoptLastGrid = !ue.has('grid') && !!lastGrid && lastGrid.length > provGrid.length;
|
||||
if (adoptLastGrid) setGrid(last.grid);
|
||||
setDetails((d) => {
|
||||
// When we adopt the last QSO's locator, take its coordinates too (derive them
|
||||
@@ -3590,6 +3727,8 @@ export default function App() {
|
||||
{ type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
|
||||
] : []),
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('logview.title'), action: 'help.log' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('help.donate'), action: 'help.donate', accent: true },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
||||
@@ -3623,6 +3762,7 @@ export default function App() {
|
||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||
case 'tools.downloadRefs': downloadRefs(); break;
|
||||
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
|
||||
case 'help.log': setShowLogViewer(true); break;
|
||||
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
|
||||
case 'help.sendlog': sendLogToDeveloper(); break;
|
||||
// Opens in the system browser, NOT the app WebView: a payment page must show
|
||||
@@ -3704,6 +3844,7 @@ export default function App() {
|
||||
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
||||
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
|
||||
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||
else if (cwSourceRef.current === 'kenwood') KenwoodStopCW().catch(() => {});
|
||||
else WinkeyerStop().catch(() => {});
|
||||
}
|
||||
if (!keyerLive || wkEscClearsRef.current) {
|
||||
@@ -3741,6 +3882,16 @@ export default function App() {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o') {
|
||||
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 (selectedId !== null) {
|
||||
if (e.key === 'Delete') { e.preventDefault(); askDeleteSelected(); return; }
|
||||
@@ -4013,7 +4164,7 @@ export default function App() {
|
||||
</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'); }} />
|
||||
</div>
|
||||
);
|
||||
@@ -4031,8 +4182,14 @@ export default function App() {
|
||||
const cls = d >= 0 && d < 7 ? 'border-success text-success bg-success/15'
|
||||
: d < 30 ? 'border-warning text-warning bg-warning/15'
|
||||
: 'border-danger text-danger bg-danger/15';
|
||||
// ARRL regenerates lotw-user-activity.csv on its own schedule and has been
|
||||
// seen frozen for a week (2026-08-02: newest line 2026-07-27). "Uploaded 7
|
||||
// days ago" then says nothing about the station, so the tooltip names the
|
||||
// day the feed itself was built whenever that day is not recent.
|
||||
const tip = t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })
|
||||
+ (lotwInfo.feed_stale && lotwInfo.feed_date ? ' — ' + t('lotw.feedStale', { date: lotwInfo.feed_date }) : '');
|
||||
return (
|
||||
<div className="shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}>
|
||||
<div className="shrink-0" title={tip}>
|
||||
<span className={cn('inline-flex items-center rounded-md border px-1.5 py-1 text-[9px] font-extrabold tracking-wide leading-none', cls)}>
|
||||
LoTW
|
||||
</span>
|
||||
@@ -4236,7 +4393,7 @@ export default function App() {
|
||||
title={catState.connected ? t('field.freqTuneHint') : undefined}
|
||||
onFocus={() => setFreqFocused(true)}
|
||||
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); }}
|
||||
/>
|
||||
</div>
|
||||
@@ -4251,7 +4408,7 @@ export default function App() {
|
||||
title={catState.connected ? t('field.freqTuneHint') : undefined}
|
||||
onFocus={() => setFreqFocused(true)}
|
||||
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); }}
|
||||
className={cn('font-mono', catState.split && 'bg-danger-muted/40 border-danger-border focus:bg-card')}
|
||||
/>
|
||||
@@ -4288,7 +4445,7 @@ export default function App() {
|
||||
</Button>
|
||||
)}
|
||||
<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" />
|
||||
{t('btn.clear')}
|
||||
</Button>
|
||||
@@ -4340,7 +4497,8 @@ export default function App() {
|
||||
const matches = clusterStatusFilter.has(st)
|
||||
|| (!!e?.worked_call && clusterStatusFilter.has('worked'))
|
||||
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|
||||
|| (!!e?.new_county && clusterStatusFilter.has('new-county'));
|
||||
|| (!!e?.new_county && clusterStatusFilter.has('new-county'))
|
||||
|| (!!e?.new_pfx && clusterStatusFilter.has('new-pfx'));
|
||||
if (!matches) return false;
|
||||
}
|
||||
if (clusterHideWorked) {
|
||||
@@ -4459,7 +4617,8 @@ export default function App() {
|
||||
// Same colours as the badges in the grid — a filter that does not
|
||||
// look like what it selects has to be learned twice.
|
||||
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
||||
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
|
||||
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
||||
{ k: 'new-pfx' as SpotFilterKey, label: 'NEW PFX', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
|
||||
]).map((s) => {
|
||||
const on = clusterStatusFilter.has(s.k);
|
||||
@@ -4620,6 +4779,7 @@ export default function App() {
|
||||
onExportFiltered={exportFilteredADIF}
|
||||
onDelete={(ids) => setDeletingIds(ids)}
|
||||
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>
|
||||
);
|
||||
@@ -5218,6 +5378,9 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live diagnostic log (Help → …). Polls only while open. */}
|
||||
<LogViewer open={showLogViewer} onOpenChange={setShowLogViewer} />
|
||||
|
||||
{/* What's new — shown once on the first launch after an update. */}
|
||||
{whatsNew && (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => setWhatsNew(null)}>
|
||||
@@ -5354,7 +5517,10 @@ export default function App() {
|
||||
{/* 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.
|
||||
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>
|
||||
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
|
||||
</div>
|
||||
@@ -5420,6 +5586,10 @@ export default function App() {
|
||||
band={band}
|
||||
mode={mode}
|
||||
bands={bands}
|
||||
{...(!callsign.trim() && selQso ? {
|
||||
slotCall: selQso.call, slotBand: selQso.band, slotMode: selQso.mode,
|
||||
slotWb: selWb, slotWbBusy: selWbBusy,
|
||||
} : {})}
|
||||
tab={detailTab}
|
||||
onTab={setDetailTab}
|
||||
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
|
||||
@@ -5502,6 +5672,9 @@ export default function App() {
|
||||
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
|
||||
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
|
||||
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)))}
|
||||
onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
|
||||
/>
|
||||
@@ -5576,11 +5749,11 @@ export default function App() {
|
||||
// exactly when its CAT backend is. Yaesu was missing from this
|
||||
// list, so the panel fell back to the WinKeyer status — which
|
||||
// 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,
|
||||
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}
|
||||
ports={wkPorts}
|
||||
@@ -5867,6 +6040,7 @@ export default function App() {
|
||||
onExportCabrilloFiltered={exportFilteredCabrillo}
|
||||
onDelete={(ids) => setDeletingIds(ids)}
|
||||
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="flex items-center gap-3">
|
||||
|
||||
@@ -189,13 +189,20 @@ function AmpBlock({ amp, flex, showName, t }: {
|
||||
const txing = typeof flex?.transmitting === 'boolean'
|
||||
? flex.transmitting
|
||||
: 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 fwdW = hold('fwd', txing ? liveW : 0, txing);
|
||||
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 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 (
|
||||
<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.
|
||||
// Remembered through writeUiPref rather than localStorage, so the choice
|
||||
// travels with a copied data/ folder like every other UI preference here.
|
||||
const [refSort, setRefSort] = useState<'ref' | 'name'>(
|
||||
() => (localStorage.getItem('opslog.awardRefSort') === 'name' ? 'name' : 'ref'));
|
||||
const [refSort, setRefSort] = useState<'ref' | 'name' | 'group'>(
|
||||
() => {
|
||||
const v = localStorage.getItem('opslog.awardRefSort');
|
||||
return v === 'name' || v === 'group' ? v : 'ref';
|
||||
});
|
||||
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
|
||||
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
|
||||
const toggleRefSort = (k: 'ref' | 'name') => {
|
||||
const toggleRefSort = (k: 'ref' | 'name' | 'group') => {
|
||||
if (k === refSort) {
|
||||
const d = refSortDir === 'asc' ? 'desc' : 'asc';
|
||||
setRefSortDir(d);
|
||||
@@ -258,10 +261,26 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
if (c !== 0) return c * dir;
|
||||
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;
|
||||
});
|
||||
}, [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 (
|
||||
<div className="flex h-full min-h-0">
|
||||
{/* 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" />)}
|
||||
</button>
|
||||
</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">
|
||||
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.description')}
|
||||
@@ -474,9 +501,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
{filteredRefs.map((r) => (
|
||||
<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="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">
|
||||
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
|
||||
</td>
|
||||
{hasGroup && (
|
||||
<td className="py-1 pr-3 font-mono font-semibold border-b border-border/30">{r.group}</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) => {
|
||||
const s = cellStatus(r, b);
|
||||
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" />)}
|
||||
</button>
|
||||
</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 font-medium">{t('awp.bands')}</th>
|
||||
</tr>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Star, Radio } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Star, Radio, Sunrise, Sunset, X, Loader2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sunTimes } from '@/lib/sun';
|
||||
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
|
||||
import type { WorkedBeforeView } from '@/types';
|
||||
|
||||
type WorkedBefore = WorkedBeforeView;
|
||||
@@ -13,6 +15,13 @@ interface Props {
|
||||
currentMode: string;
|
||||
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
||||
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
||||
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
|
||||
// to an entity with no position at all, and the block simply does not appear.
|
||||
lat?: 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,
|
||||
@@ -84,7 +93,9 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
|
||||
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
||||
}
|
||||
|
||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: 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
|
||||
// bands they actually use), falling back to the built-in default set.
|
||||
const cols = useMemo(
|
||||
@@ -151,6 +162,36 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
</Badge>
|
||||
) : null;
|
||||
|
||||
// Sunrise / sunset AT THE DX, in UTC. A glance tells you whether the path is
|
||||
// about to open or close on their side, which is the reason to look at a DX
|
||||
// station's grey line at all. Recomputed only when the position changes.
|
||||
const sun = useMemo(
|
||||
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
|
||||
[lat, lon],
|
||||
);
|
||||
const sunBlock = sun ? (
|
||||
<div className="ml-auto flex items-center gap-3 text-xs shrink-0"
|
||||
title="Sunrise / sunset at the DX station (UTC)">
|
||||
{sun.polarDay ? (
|
||||
<span className="font-semibold text-warning">midnight sun</span>
|
||||
) : sun.polarNight ? (
|
||||
<span className="font-semibold text-info">polar night</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<Sunrise className="size-3.5 text-warning" />
|
||||
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Sunset className="size-3.5 text-info" />
|
||||
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">UTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
@@ -158,7 +199,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-[220px]">
|
||||
<div className="flex items-center gap-2 min-w-[220px] flex-1">
|
||||
{newOne ? (
|
||||
<>
|
||||
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
||||
@@ -173,6 +214,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
</>
|
||||
) : 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">
|
||||
{dxccName || `DXCC #${dxcc}`}
|
||||
</Badge>
|
||||
@@ -207,6 +256,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
Type a callsign to see entity stats
|
||||
</span>
|
||||
)}
|
||||
{sunBlock}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -246,10 +296,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
return (
|
||||
<td
|
||||
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(
|
||||
'w-[28px] h-[24px] rounded transition-colors p-0',
|
||||
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',
|
||||
)}
|
||||
/>
|
||||
@@ -277,6 +331,107 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{slot && (
|
||||
<SlotQSOModal
|
||||
call={wb?.callsign ?? ''}
|
||||
dxcc={dxcc}
|
||||
entity={dxccName}
|
||||
band={slot.band}
|
||||
cls={slot.cls}
|
||||
onClose={() => setSlot(null)}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ export type SpotStatusEntry = {
|
||||
worked_call?: boolean;
|
||||
new_county?: boolean;
|
||||
new_pota?: boolean;
|
||||
new_pfx?: boolean;
|
||||
pfx?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -99,47 +101,42 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
||||
];
|
||||
}
|
||||
|
||||
// statusBadge maps a resolved spot status to a short labelled badge for the
|
||||
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
||||
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
||||
// there's nothing notable to show.
|
||||
type Badge = { text: string; fg: string; bg: string; bd: string };
|
||||
// Spot status is shown by COLOURING THE TEXT, never by a pill or a badge.
|
||||
//
|
||||
// The pills were dropped: a rounded box has its own height and padding, so it
|
||||
// sat off the row's baseline and the callsign inside it no longer lined up with
|
||||
// the plain callsigns above and below. A whole column of them read as chrome
|
||||
// rather than as data. Same reasoning — and the same semantic tokens — as the
|
||||
// Y/N/R QSL columns in lib/qslStatus.ts.
|
||||
//
|
||||
// Which cell is coloured IS the message, so one colour is enough for all three:
|
||||
//
|
||||
// yellow call → new DXCC yellow band → new band yellow mode → new mode
|
||||
// blue call → already worked
|
||||
const NEW = 'var(--warning)'; // yellow: something here is new
|
||||
const WKD = 'var(--info)'; // blue: this callsign is already in the log
|
||||
|
||||
// tok builds a badge from a semantic status token (success/warning/caution/
|
||||
// danger/info/neutral) so the colours adapt to every theme via CSS variables,
|
||||
// instead of the hard-coded light-theme pastels that looked wrong in dark mode.
|
||||
function tok(name: string, text: string): Badge {
|
||||
if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' };
|
||||
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
|
||||
}
|
||||
|
||||
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
|
||||
// Status badges) when the slot is notable, instead of flooding the whole cell with
|
||||
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
|
||||
// null → plain text, no pill.
|
||||
function cellChip(value: any, name: string | null): any {
|
||||
// cellText renders a cell value in an optional colour. An empty value keeps the
|
||||
// muted dash the grid used before, so blank cells still read as "nothing here"
|
||||
// rather than as a gap.
|
||||
function cellText(value: any, color: string | null): any {
|
||||
const txt = value === undefined || value === null || value === '' ? '' : String(value);
|
||||
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||
// Inherit the column's font size (no fixed 9px / height) so a pill around a
|
||||
// callsign stays the same size as the plain callsigns next to it — just tinted
|
||||
// and rounded, not shrunk.
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', lineHeight: 1.1,
|
||||
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
|
||||
border: `1px solid var(--${name}-border)`, fontWeight: 700,
|
||||
padding: '1px 6px', borderRadius: 999, whiteSpace: 'nowrap',
|
||||
}}>{txt}</span>
|
||||
);
|
||||
if (!txt) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||
return <span style={color ? { color, fontWeight: 700 } : undefined}>{txt}</span>;
|
||||
}
|
||||
|
||||
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
|
||||
// statusColor is the colour for a resolved status in the Status column. County,
|
||||
// POTA and prefix keep their own tokens: they are orthogonal to the band/mode/
|
||||
// DXCC story and an operator filters on them separately.
|
||||
function statusColor(s: SpotStatusEntry | undefined): string | null {
|
||||
switch (s?.status) {
|
||||
case 'new': return tok('danger', t('clg2.newDxcc'));
|
||||
case 'new-band': return tok('warning', t('clg2.newBand'));
|
||||
case 'new-mode': return tok('caution', t('clg2.newMode'));
|
||||
case 'new-slot': return tok('caution', t('clg2.newSlot'));
|
||||
default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null;
|
||||
case 'new':
|
||||
case 'new-band':
|
||||
case 'new-mode':
|
||||
case 'new-slot':
|
||||
return NEW;
|
||||
default:
|
||||
return s?.worked_call ? WKD : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,15 +163,12 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
// NEW DXCC → a danger pill around the call, consistent with the NEW BAND /
|
||||
// NEW MODE pills (same look, same tokens). Worked-call stays blue bold text;
|
||||
// a plain spot inherits the theme's normal text colour so callsigns blend in
|
||||
// with the rest of the row instead of always shouting a colour.
|
||||
// NEW DXCC → yellow call. Already worked → blue call. Anything else keeps
|
||||
// the theme's normal ink so ordinary callsigns don't shout.
|
||||
cellRenderer: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return cellChip(p.value, 'danger');
|
||||
const color = s?.worked_call ? 'var(--info)' : undefined;
|
||||
return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
|
||||
const color = s?.status === 'new' ? NEW : s?.worked_call ? WKD : null;
|
||||
return <span style={{ color: color ?? undefined, fontWeight: 700 }}>{p.value ?? ''}</span>;
|
||||
},
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
@@ -185,9 +179,10 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
||||
headerName: t('clg2.c.status'), width: 120, sortable: true,
|
||||
defaultVisible: true,
|
||||
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
||||
// is obvious at the row level, not just a single coloured cell. NEW COUNTY
|
||||
// and NEW POTA are orthogonal, so they stack as extra badges.
|
||||
// Spells the status out in words so NEW SLOT (and the others) is obvious at
|
||||
// the row level, not just a single coloured cell — NEW SLOT in particular
|
||||
// colours no cell at all, since neither the band nor the mode is new on its
|
||||
// own. NEW COUNTY and NEW POTA are orthogonal, so they stack after it.
|
||||
valueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
const parts: string[] = [];
|
||||
@@ -198,25 +193,31 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
|
||||
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
||||
if (s?.new_pota) parts.push(t('clg2.newPota'));
|
||||
if (s?.new_pfx) parts.push(t('clg2.newPfx'));
|
||||
return parts.join(' ');
|
||||
},
|
||||
cellRenderer: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
const badges: Badge[] = [];
|
||||
const b = statusBadge(t, s);
|
||||
if (b) badges.push(b);
|
||||
if (s?.new_county) badges.push(tok('info', t('clg2.newCounty')));
|
||||
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota')));
|
||||
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||
const parts: { text: string; color: string }[] = [];
|
||||
const main = statusColor(s);
|
||||
if (main) {
|
||||
const label = s?.status === 'new' ? t('clg2.newDxcc')
|
||||
: s?.status === 'new-band' ? t('clg2.newBand')
|
||||
: s?.status === 'new-mode' ? t('clg2.newMode')
|
||||
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
||||
: t('clg2.wkdCall');
|
||||
parts.push({ text: label, color: main });
|
||||
}
|
||||
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: 'var(--success)' });
|
||||
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: 'var(--success)' });
|
||||
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: 'var(--caution)' });
|
||||
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||
return (
|
||||
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
|
||||
{badges.map((bd, i) => (
|
||||
<span key={i} style={{
|
||||
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
|
||||
backgroundColor: bd.bg, color: bd.fg, border: `1px solid ${bd.bd}`,
|
||||
fontWeight: 700, fontSize: 9,
|
||||
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
||||
}}>{bd.text}</span>
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
{parts.map((pt, i) => (
|
||||
<span key={i} style={{ color: pt.color, fontWeight: 700 }}>
|
||||
{i > 0 ? ' · ' : ''}{pt.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
@@ -249,8 +250,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
// NEW BAND for this entity → small warning pill around the band text.
|
||||
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null),
|
||||
// NEW BAND for this entity → the band text turns yellow.
|
||||
cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-band' ? NEW : null),
|
||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
||||
},
|
||||
{
|
||||
@@ -263,7 +264,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
// for the entity. NEW SLOT means band AND mode were each worked before (just
|
||||
// not together), so highlighting the mode cell would wrongly imply "CW is new";
|
||||
// that case is signalled by the Status badge alone.
|
||||
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null),
|
||||
cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-mode' ? NEW : null),
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||
|
||||
@@ -69,6 +69,14 @@ interface Props {
|
||||
band: string;
|
||||
mode: string;
|
||||
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;
|
||||
onOpenImage?: () => void;
|
||||
// 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 [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||
@@ -215,6 +223,15 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
||||
}
|
||||
return null;
|
||||
}, [operatorGrid, remoteGrid, details.lat, details.lon]);
|
||||
// Where the DX actually is, for its sunrise/sunset. The locator wins when we
|
||||
// have one — it is what the operator can see and check — and the provider's
|
||||
// raw coordinates cover entities that resolve through cty.dat with no grid.
|
||||
const dxLL = useMemo(() => {
|
||||
const byGrid = gridToLatLon(remoteGrid);
|
||||
if (byGrid) return byGrid;
|
||||
if (details.lat != null && details.lon != null) return { lat: details.lat, lon: details.lon };
|
||||
return null;
|
||||
}, [remoteGrid, details.lat, details.lon]);
|
||||
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
||||
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
||||
|
||||
@@ -263,7 +280,15 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
||||
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
|
||||
{open === 'stats' && (
|
||||
<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} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -234,7 +234,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
<SelectItem key={n} value={n}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{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>
|
||||
</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
|
||||
// as the app lagging rather than as a peak being held.
|
||||
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 p = peak.current[key];
|
||||
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;
|
||||
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
|
||||
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); };
|
||||
}, []);
|
||||
|
||||
@@ -1114,11 +1117,32 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
|
||||
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 dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||
&& !/^(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
|
||||
// amplifier also reports a "peakfwd", and using it was a mistake
|
||||
// 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';
|
||||
// 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
|
||||
// value itself is fine — only the bar's range was wrong. Give it a
|
||||
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
|
||||
// only override an unusable reported range, not a sane one.
|
||||
// for this meter, so it pinned the bar. Give it a fixed 50 A scale
|
||||
// (the LDMOS pair can pull ~45-50 A near full output), overriding an
|
||||
// unusable reported range but keeping a sane one.
|
||||
let lo = m.lo, hi = m.hi;
|
||||
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
||||
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} />;
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { TailLogFile, GetLogFilePath } from '../../wailsjs/go/main/App';
|
||||
|
||||
// LogViewer shows the tail of opslog.log and keeps it current while open.
|
||||
//
|
||||
// This exists because diagnosing a problem used to mean asking the operator to
|
||||
// find a file in their data directory, open it in a text editor, and re-open it
|
||||
// after every attempt. Half the round-trips in a bug report were spent on that.
|
||||
//
|
||||
// Three behaviours matter more than they look:
|
||||
// • Auto-scroll is a checkbox the operator owns, AND it releases itself the
|
||||
// moment they scroll up — otherwise the line they are reading is yanked away
|
||||
// a second later, which defeats the one thing this window is for. Scrolling
|
||||
// back to the bottom re-arms it, so following is never a dead end.
|
||||
// • The filter keeps only matching LINES rather than highlighting inside a
|
||||
// 256 KB blob: with "cluster" or "acom" typed in, the window becomes exactly
|
||||
// the subsystem trace you would have grepped for.
|
||||
// • It polls only while open. A closed dialog costs nothing.
|
||||
// 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;
|
||||
|
||||
export function LogViewer({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [text, setText] = useState('');
|
||||
const [path, setPath] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
// Shown next to the checkbox so a QUIET log still proves it is being polled —
|
||||
// without it there is no way to tell 'nothing new' from 'window is dead'.
|
||||
const [lastPull, setLastPull] = useState('');
|
||||
const boxRef = useRef<HTMLPreElement>(null);
|
||||
const autoRef = useRef(true);
|
||||
useEffect(() => { autoRef.current = autoScroll; }, [autoScroll]);
|
||||
|
||||
// stick pins the view to the bottom on the NEXT frame. Setting scrollTop
|
||||
// straight away is not enough while the dialog is still animating in: the box
|
||||
// has no height yet, scrollHeight is wrong, and the scroll silently does
|
||||
// nothing — leaving the operator staring at the top of a 256 KB buffer, i.e.
|
||||
// at lines from hours ago, which looks exactly like a frozen window.
|
||||
const stick = useCallback(() => {
|
||||
if (!autoRef.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
const el = boxRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pull = useCallback(() => {
|
||||
TailLogFile(TAIL_BYTES).then((s: any) => {
|
||||
setText(String(s ?? ''));
|
||||
setLastPull(new Date().toLocaleTimeString());
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setAutoScroll(true);
|
||||
autoRef.current = true;
|
||||
GetLogFilePath().then((p: any) => setPath(String(p ?? ''))).catch(() => {});
|
||||
pull();
|
||||
// Re-assert the bottom after the open animation has settled. A quiet log
|
||||
// never changes, so the content-driven scroll below would never fire again
|
||||
// and the first attempt would be the only one.
|
||||
const t1 = window.setTimeout(stick, 120);
|
||||
const t2 = window.setTimeout(stick, 450);
|
||||
const id = window.setInterval(pull, POLL_MS);
|
||||
return () => { window.clearInterval(id); window.clearTimeout(t1); window.clearTimeout(t2); };
|
||||
}, [open, pull, stick]);
|
||||
|
||||
const all = useMemo(() => text.split('\n'), [text]);
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter((l) => l.toLowerCase().includes(q));
|
||||
}, [all, query]);
|
||||
|
||||
// Stick to the bottom after each refresh, but only while auto-scroll is on.
|
||||
useEffect(() => { if (open) stick(); }, [shown, open, stick]);
|
||||
|
||||
// Scrolling away from the bottom releases auto-scroll; coming back re-arms it.
|
||||
function onScroll() {
|
||||
const el = boxRef.current;
|
||||
if (!el) return;
|
||||
setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 24);
|
||||
}
|
||||
|
||||
const body = shown.join('\n');
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('logview.title')}</DialogTitle>
|
||||
<DialogDescription className="font-mono text-[11px] break-all">{path}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 flex-1 font-mono text-xs" value={query} placeholder={t('logview.searchPh')}
|
||||
onChange={(e) => setQuery(e.target.value)} />
|
||||
{query.trim() !== '' && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||
{t('logview.matches', { n: shown.length, total: all.length })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<pre ref={boxRef} onScroll={onScroll}
|
||||
className="h-[60vh] overflow-auto rounded-md border border-border bg-muted/30 p-2 text-[11px] leading-snug font-mono whitespace-pre-wrap break-all">
|
||||
{body.trim() === '' ? t('logview.empty') : body}
|
||||
</pre>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<Checkbox checked={autoScroll} onCheckedChange={(c) => {
|
||||
const on = !!c;
|
||||
setAutoScroll(on);
|
||||
if (on) { const el = boxRef.current; if (el) el.scrollTop = el.scrollHeight; }
|
||||
}} />
|
||||
{t('logview.autoScroll')}
|
||||
</label>
|
||||
{lastPull && <span className="text-xs text-muted-foreground tabular-nums">{t('logview.updated', { time: lastPull })}</span>}
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" size="sm" onClick={() => navigator.clipboard?.writeText(body)}>
|
||||
{t('logview.copy')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setQuery(''); setAutoScroll(true); pull(); }}>
|
||||
{t('logview.toEnd')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
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 { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
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; }
|
||||
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
||||
}, [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 [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
||||
const [potaErr, setPotaErr] = useState('');
|
||||
|
||||
@@ -56,6 +56,10 @@ type Props = {
|
||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||
onRowClicked?: (q: QSOForm) => 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;
|
||||
onUpdateFromQRZ?: (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[] =>
|
||||
(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 gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -542,6 +546,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
setSelCount(sel.length);
|
||||
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
|
||||
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).
|
||||
useEffect(() => {
|
||||
|
||||
@@ -24,6 +24,9 @@ interface Props {
|
||||
centerLat?: number | null; // operator latitude (projection centre)
|
||||
centerLon?: number | null; // operator longitude
|
||||
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
|
||||
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)];
|
||||
}
|
||||
|
||||
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(
|
||||
() => [ { 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' } ],
|
||||
@@ -100,6 +103,31 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
||||
)}
|
||||
</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">
|
||||
<svg
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
GetListsSettings, SaveListsSettings,
|
||||
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||
@@ -80,7 +80,7 @@ type StationSettings = StationSettingsForm;
|
||||
type ListsSettings = ListsSettingsForm;
|
||||
type ModePreset = ModePresetForm;
|
||||
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 ClusterServerStatus = Omit<clusterModels.ServerStatus, 'convertValues'>;
|
||||
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||||
@@ -1136,14 +1136,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [modeDraft, setModeDraft] = useState('');
|
||||
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,
|
||||
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,
|
||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
||||
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
|
||||
});
|
||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
||||
enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1,
|
||||
} as any);
|
||||
// While true, the next key press is captured as the PTT hotkey.
|
||||
const [capturingPtt, setCapturingPtt] = useState(false);
|
||||
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
|
||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||
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 [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '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 [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
||||
// Password-encryption (secret vault) state.
|
||||
@@ -1498,7 +1500,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
try {
|
||||
const [l, ls, c, ap, r, b, qd, es] = await Promise.all([
|
||||
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
|
||||
GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
|
||||
GetRotators(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
|
||||
]);
|
||||
setLookup(l);
|
||||
setActiveProfile(ap as Profile);
|
||||
@@ -1511,7 +1513,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
await reloadProfiles();
|
||||
await reloadClusterServers();
|
||||
setCatCfg(c);
|
||||
setRotator(r);
|
||||
setRotors((r ?? []) as any);
|
||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||
try { setAntgenius(await GetAntGeniusSettings() 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 { setLookup(await GetLookupSettings() 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 { setAntgenius(await GetAntGeniusSettings() 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 SaveCATSettings(catCfg as any);
|
||||
await SaveRotatorSettings(rotator as any);
|
||||
await SaveRotators(rotors as any);
|
||||
await SaveUltrabeamSettings(ultrabeam as any);
|
||||
await SaveAntGeniusSettings(antgenius 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">
|
||||
<Checkbox checked={civTrace} onCheckedChange={(c) => { setCivTrace(!!c); SetCIVTrace(!!c); }} />
|
||||
{t('cat.civTrace')}
|
||||
</label>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{t('cat.civTraceHint')}</p>
|
||||
</div>
|
||||
</label> </div>
|
||||
)}
|
||||
{catCfg.backend === 'kenwood' && (
|
||||
<>
|
||||
@@ -2612,9 +2612,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</Select>
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{t('cat.kenwoodPortHint')}</span>
|
||||
</div>
|
||||
</div> </div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.baud')}</Label>
|
||||
<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>
|
||||
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground">{t('cat.kenwoodBaudHint')}</span>
|
||||
</div>
|
||||
</Select> </div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.kenwoodHost')}</Label>
|
||||
<Input
|
||||
value={catCfg.kenwood_host || ''}
|
||||
placeholder="192.168.1.50:4532"
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, kenwood_host: e.target.value }))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('cat.kenwoodHostHint')}</span>
|
||||
</div>
|
||||
/> </div>
|
||||
<div className="space-y-1">
|
||||
<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 }))} />
|
||||
@@ -2641,6 +2635,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
|
||||
</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' && (
|
||||
@@ -2811,6 +2815,52 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</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' && (
|
||||
<>
|
||||
<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);
|
||||
setRotatorTest(null);
|
||||
try {
|
||||
await TestRotator(rotator as any);
|
||||
setRotatorTest({ ok: true, msg: (rotator as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
|
||||
await TestRotatorDevice(dev as any, sub);
|
||||
setRotatorTest({ ok: true, msg: (dev as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
|
||||
} catch (e: any) {
|
||||
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
||||
} finally {
|
||||
@@ -3224,6 +3274,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</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
|
||||
CAT link (ACOM and SPE both do) — never PowerGenius, which is
|
||||
@@ -3298,136 +3360,202 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Button variant="outline" size="sm" onClick={addAmp}>
|
||||
<Plus className="size-3.5 mr-1" /> {t('amp.add')}
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RotatorPanel() {
|
||||
const isRG = (rotator as any).type === 'rotgenius';
|
||||
const isARCO = (rotator as any).type === 'arco';
|
||||
// Rotors are a list, like amplifiers: add with +, remove with the trash icon.
|
||||
// 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 (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Rotator"
|
||||
hint={isRG || isARCO ? undefined : t('rot.hint')}
|
||||
/>
|
||||
<SectionHeader title="Rotator" hint={anyPst ? t('rot.hint') : undefined} />
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
|
||||
{t('rot.enable')}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('rot.type')}</Label>
|
||||
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
|
||||
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
|
||||
<Select value={(rotator as any).type ?? 'pst'}
|
||||
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 } as any))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||||
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
||||
<SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC…)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{isRG && (
|
||||
<div className="space-y-1">
|
||||
<Label>{t('rot.rotatorNum')}</Label>
|
||||
<Select value={String((rotator as any).rotator_num ?? 1)}
|
||||
onValueChange={(v) => setRotator((s) => ({ ...s, rotator_num: parseInt(v, 10) || 1 } as any))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1</SelectItem>
|
||||
<SelectItem value="2">2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{rotors.length === 0 && <p className="text-sm text-muted-foreground">{t('rot.none')}</p>}
|
||||
{rotors.map((d, i) => {
|
||||
const dev = d as any;
|
||||
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="space-y-1">
|
||||
<Label>{t('rot.type')}</Label>
|
||||
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
|
||||
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
|
||||
<Select value={dev.type ?? 'pst'}
|
||||
onValueChange={(v) => patch(i, { type: v as any, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 })}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||||
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
||||
<SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC…)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{isRG && !dev.dual && (
|
||||
<div className="space-y-1">
|
||||
<Label>{t('rot.rotatorNum')}</Label>
|
||||
<Select value={String(dev.rotator_num ?? 1)}
|
||||
onValueChange={(v) => patch(i, { rotator_num: parseInt(v, 10) || 1 })}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1</SelectItem>
|
||||
<SelectItem value="2">2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{/* The ARCO is reachable over the LAN (TCP) or its USB virtual COM. */}
|
||||
{isARCO && (
|
||||
<div className="space-y-1">
|
||||
<Label>Connection</Label>
|
||||
<Select value={transport} onValueChange={(v) => patch(i, { transport: v as any })}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">Network (LAN)</SelectItem>
|
||||
<SelectItem value="serial">USB (serial COM)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 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">
|
||||
<Label>COM port</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={dev.com_port || '_'} onValueChange={(v) => patch(i, { com_port: v === '_' ? '' : v })}>
|
||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<ArrowDown className="size-3.5 rotate-90" />
|
||||
</Button>
|
||||
<Select value={String(dev.baud || 9600)} onValueChange={(v) => patch(i, { baud: Number(v) })}>
|
||||
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{[4800, 9600, 19200, 38400, 57600].map((b) => (
|
||||
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>Host / IP</Label>
|
||||
<Input value={dev.host ?? ''} onChange={(e) => patch(i, { host: e.target.value })}
|
||||
placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
|
||||
<PortInput value={dev.port} onChange={(n) => patch(i, { port: n })}
|
||||
fallback={isRG ? 9006 : isARCO ? 4001 : 12000} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isRG && !isARCO && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={!!dev.has_elevation} onCheckedChange={(c) => patch(i, { has_elevation: !!c })} />
|
||||
This rotator supports elevation (VHF / satellite)
|
||||
</label>
|
||||
)}
|
||||
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||||
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
||||
{/* Which antenna this rotor carries — only relevant with >1 rotor. */}
|
||||
{multi && (
|
||||
<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>
|
||||
{isRG && dev.dual && (
|
||||
<Button variant="outline" size="sm" onClick={() => testRotator(d, 2)} disabled={rotatorTesting}>
|
||||
{t('rot.testRotor2')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* The ARCO is reachable over the LAN (TCP) or its USB virtual COM. */}
|
||||
{isARCO && (
|
||||
<div className="space-y-1">
|
||||
<Label>Connection</Label>
|
||||
<Select value={(rotator as any).transport ?? 'tcp'}
|
||||
onValueChange={(v) => setRotator((s) => ({ ...s, transport: v } as any))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">Network (LAN)</SelectItem>
|
||||
<SelectItem value="serial">USB (serial COM)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isARCO && (rotator as any).transport === 'serial' ? (
|
||||
<div className="space-y-1 max-w-xs">
|
||||
<Label>COM port</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={(rotator as any).com_port || '_'}
|
||||
onValueChange={(v) => setRotator((s) => ({ ...s, com_port: v === '_' ? '' : v } as any))}>
|
||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<ArrowDown className="size-3.5 rotate-90" />
|
||||
</Button>
|
||||
<Select value={String((rotator as any).baud || 9600)}
|
||||
onValueChange={(v) => setRotator((s) => ({ ...s, baud: Number(v) } as any))}>
|
||||
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{[4800, 9600, 19200, 38400, 57600].map((b) => (
|
||||
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>Host / IP</Label>
|
||||
<Input
|
||||
value={rotator.host ?? ''}
|
||||
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 className="space-y-1">
|
||||
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
|
||||
<PortInput
|
||||
value={rotator.port}
|
||||
onChange={(n) => setRotator((s) => ({ ...s, port: n }))} fallback={isRG ? 9006 : isARCO ? 4001 : 12000}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isRG && !isARCO && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
||||
This rotator supports elevation (VHF / satellite)
|
||||
</label>
|
||||
)}
|
||||
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||||
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
||||
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
||||
Stop
|
||||
</Button>
|
||||
{!isRG && (
|
||||
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
||||
Park
|
||||
);
|
||||
})}
|
||||
<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
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{anyPst && (
|
||||
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))}>
|
||||
Park
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{rotatorTest && (
|
||||
<div className={cn(
|
||||
'text-xs rounded-md p-2.5 border',
|
||||
@@ -3439,8 +3567,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
From the main entry strip, click the bearing pill to rotate to the short-path azimuth.
|
||||
Shift+click for long-path, Ctrl+click to stop.
|
||||
From the main entry strip, click the bearing pill to rotate the active rotor to the short-path
|
||||
azimuth. Shift+click for long-path, Ctrl+click to stop. With several rotors, pick the active one
|
||||
on the compass widget.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -3478,6 +3607,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<SelectItem value="serial">{t('wk.engSerial')}</SelectItem>
|
||||
<SelectItem value="icom">{t('wk.engIcom')}</SelectItem>
|
||||
<SelectItem value="yaesu">{t('wk.engYaesu')}</SelectItem>
|
||||
<SelectItem value="kenwood">{t('wk.engKenwood')}</SelectItem>
|
||||
<SelectItem value="flex">{t('wk.engFlex')}</SelectItem>
|
||||
<SelectItem value="tci" disabled>{t('wk.engTci')}</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -3524,6 +3654,22 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</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' ? (
|
||||
<>
|
||||
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
||||
@@ -3855,6 +4001,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<span className="font-mono">dx.maritimecontestclub.net:7300</span>,{' '}
|
||||
<span className="font-mono">w8avi.net:7300</span>.
|
||||
</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>
|
||||
|
||||
{editingServer && (
|
||||
|
||||
@@ -13,7 +13,7 @@ import { TunerCard } from '@/components/TunerCard';
|
||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||
import {
|
||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||
GetAmpStatuses, GetFlexState,
|
||||
@@ -33,19 +33,21 @@ type DevStatus = { id: string; name: string; type: string; connected: boolean; e
|
||||
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
|
||||
const GRID_GAP = 16; // px between cards, both axes
|
||||
|
||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 };
|
||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
|
||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8, dingtian: 2 };
|
||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)', dingtian: 'Dingtian IOT relay' };
|
||||
|
||||
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
|
||||
// the generic USB-serial board, whose channel count the user picks.
|
||||
const chanCount = (d: Device): number =>
|
||||
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : (RELAY_COUNT[d.type] ?? 5);
|
||||
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8)
|
||||
: d.type === 'dingtian' ? (d.channels && d.channels >= 1 ? d.channels : 2)
|
||||
: (RELAY_COUNT[d.type] ?? 5);
|
||||
|
||||
function blankDevice(): Device {
|
||||
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
|
||||
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
||||
@@ -80,6 +82,9 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
||||
centerLat={centerLat ?? null}
|
||||
centerLon={centerLon ?? null}
|
||||
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)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
@@ -552,6 +557,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
onChange({ ...device, channels, labels });
|
||||
};
|
||||
const isKM = device.type === 'kmtronic';
|
||||
const isDingtian = device.type === 'dingtian';
|
||||
const isDenkovi = device.type === 'denkovi';
|
||||
const isUsbRelay = device.type === 'usbrelay';
|
||||
// COM ports for the generic USB-serial relay picker.
|
||||
@@ -602,6 +608,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
<SelectContent>
|
||||
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||||
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||||
<SelectItem value="dingtian">Dingtian IOT relay (LAN / WiFi)</SelectItem>
|
||||
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
|
||||
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -612,13 +619,13 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
{(isDenkovi || isUsbRelay) && (
|
||||
{(isDenkovi || isUsbRelay || isDingtian) && (
|
||||
<div className="space-y-1 max-w-[10rem]">
|
||||
<Label>{t('station.channels')}</Label>
|
||||
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(isDenkovi ? [4, 8] : [1, 2, 4, 8, 16]).map((n) => (
|
||||
{(isDenkovi ? [4, 8] : isDingtian ? [2, 4, 8, 16, 24, 32] : [1, 2, 4, 8, 16]).map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -661,11 +668,28 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
||||
<div className={cn('grid gap-3', (isKM || isDingtian) ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||
<div className={cn('space-y-1', (isKM || isDingtian) ? '' : 'max-w-xs')}>
|
||||
<Label>{t('station.host')}</Label>
|
||||
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||
</div>
|
||||
{/* Dingtian: both are OFF on a factory board — the session ID only when
|
||||
"HTTP Session" is enabled in its web page, the password only when a
|
||||
relay password is set. */}
|
||||
{isDingtian && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.dtSession')}</Label>
|
||||
<Input className="font-mono" value={device.user ?? ''} placeholder={t('station.optional')}
|
||||
onChange={(e) => onChange({ ...device, user: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.dtPwd')}</Label>
|
||||
<Input className="font-mono" value={device.pass ?? ''} placeholder="0"
|
||||
onChange={(e) => onChange({ ...device, pass: e.target.value.replace(/[^0-9]/g, '') })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isKM && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
wpm: number;
|
||||
macros: WKMacro[];
|
||||
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
|
||||
onSetBreakIn?: (mode: number) => void;
|
||||
onSelectPort: (p: string) => void;
|
||||
@@ -101,16 +101,16 @@ export function WinkeyerPanel({
|
||||
<Radio className="size-4 text-primary shrink-0" />
|
||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||
<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 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')} />
|
||||
<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">
|
||||
{source === 'flex'
|
||||
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||
: source === 'yaesu'
|
||||
: source === 'yaesu' || source === 'kenwood'
|
||||
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
||||
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||
</span>
|
||||
|
||||
+20
-16
File diff suppressed because one or more lines are too long
@@ -0,0 +1,83 @@
|
||||
// Sunrise / sunset for a station, in UTC.
|
||||
//
|
||||
// Built on greyline.ts's sunPosition() rather than on a second implementation of
|
||||
// the solar equations: the map's terminator and the entry strip's sunrise must
|
||||
// never disagree, and there is a whole comment block over there about a sign
|
||||
// error that produced six months of plausible-looking wrong answers. One source
|
||||
// of astronomy, one place to be wrong.
|
||||
//
|
||||
// Everything is UTC. Amateur radio runs on UTC, the log is in UTC, and a local
|
||||
// time would raise the question "local to whom — me or the DX?".
|
||||
import { sunPosition } from './greyline';
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
// Standard altitude of the Sun's centre at rise/set: half a solar diameter below
|
||||
// the horizon plus atmospheric refraction.
|
||||
const H0 = -0.833;
|
||||
|
||||
// solarAltitude returns the Sun's elevation in degrees at a place and instant.
|
||||
function solarAltitude(date: Date, latDeg: number, lonDeg: number): number {
|
||||
const { dec, gha } = sunPosition(date);
|
||||
const ha = (gha + lonDeg) * DEG; // local hour angle
|
||||
const d = dec * DEG;
|
||||
const lat = latDeg * DEG;
|
||||
return Math.asin(Math.sin(lat) * Math.sin(d) + Math.cos(lat) * Math.cos(d) * Math.cos(ha)) / DEG;
|
||||
}
|
||||
|
||||
export type SunTimes = {
|
||||
rise: string; // "HH:MM" UTC, "" when the Sun does not rise that day
|
||||
set: string;
|
||||
polarDay: boolean; // Sun stays up all day
|
||||
polarNight: boolean; // Sun never comes up
|
||||
};
|
||||
|
||||
// sunTimes scans the UTC day a minute at a time and reports where the Sun
|
||||
// crosses the rise/set altitude.
|
||||
//
|
||||
// A scan, not the closed-form hour-angle formula, because the closed form needs
|
||||
// special-casing for the polar cases and for days where the declination shifts
|
||||
// enough to matter, and it silently returns NaN rather than saying "no sunrise
|
||||
// here today". 1440 evaluations is nothing, and the failure mode is honest: no
|
||||
// crossing found means no sunrise, which is a real answer above the Arctic
|
||||
// circle. The crossing minute is then refined by linear interpolation, so the
|
||||
// result is accurate to well under a minute despite the coarse scan.
|
||||
export function sunTimes(date: Date, latDeg: number, lonDeg: number): SunTimes {
|
||||
const out: SunTimes = { rise: '', set: '', polarDay: false, polarNight: false };
|
||||
if (!isFinite(latDeg) || !isFinite(lonDeg)) return out;
|
||||
|
||||
const start = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||
const minute = 60000;
|
||||
let prev = solarAltitude(new Date(start), latDeg, lonDeg);
|
||||
const anyUp = prev > H0;
|
||||
let anyDown = prev <= H0;
|
||||
let sawUp = anyUp;
|
||||
|
||||
for (let i = 1; i <= 1440; i++) {
|
||||
const alt = solarAltitude(new Date(start + i * minute), latDeg, lonDeg);
|
||||
if (alt > H0) sawUp = true; else anyDown = true;
|
||||
if (prev <= H0 && alt > H0 && !out.rise) {
|
||||
out.rise = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
||||
} else if (prev > H0 && alt <= H0 && !out.set) {
|
||||
out.set = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
||||
}
|
||||
prev = alt;
|
||||
}
|
||||
if (!out.rise && !out.set) {
|
||||
out.polarDay = sawUp && !anyDown;
|
||||
out.polarNight = !sawUp;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// frac is where between two samples the altitude crosses H0.
|
||||
function frac(a: number, b: number): number {
|
||||
const d = b - a;
|
||||
return d === 0 ? 0 : (H0 - a) / d;
|
||||
}
|
||||
|
||||
function fmt(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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).
|
||||
export const APP_VERSION = '0.22.9';
|
||||
export const APP_VERSION = '0.23.5';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+21
-3
@@ -87,6 +87,8 @@ export function AwardsFolder():Promise<string>;
|
||||
|
||||
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 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 CancelConfirmations():Promise<void>;
|
||||
|
||||
export function ChatAvailable():Promise<boolean>;
|
||||
|
||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||
@@ -469,7 +473,7 @@ export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
||||
|
||||
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>;
|
||||
|
||||
@@ -607,6 +611,10 @@ export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
||||
|
||||
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 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 PTTHotkeyDown():Promise<void>;
|
||||
|
||||
export function PTTHotkeyUp():Promise<void>;
|
||||
|
||||
export function PickADIFMonitorFile():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 SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||
export function SaveRotators(arg1:Array<main.RotatorDevice>):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 SetActiveRotor(arg1:number):Promise<void>;
|
||||
|
||||
export function SetAlertEmailTo(arg1:string):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 SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function SetPassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||
@@ -989,6 +1005,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||
|
||||
export function TailLogFile(arg1:number):Promise<string>;
|
||||
|
||||
export function TestCloudlogUpload():Promise<string>;
|
||||
|
||||
export function TestClublogUpload():Promise<string>;
|
||||
@@ -1009,7 +1027,7 @@ export function TestPTT(arg1:main.AudioSettings):Promise<void>;
|
||||
|
||||
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>;
|
||||
|
||||
|
||||
@@ -122,6 +122,10 @@ export function 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() {
|
||||
return window['go']['main']['App']['BrowseExecutable']();
|
||||
}
|
||||
@@ -142,6 +146,10 @@ export function CWDecoderRunning() {
|
||||
return window['go']['main']['App']['CWDecoderRunning']();
|
||||
}
|
||||
|
||||
export function CancelConfirmations() {
|
||||
return window['go']['main']['App']['CancelConfirmations']();
|
||||
}
|
||||
|
||||
export function ChatAvailable() {
|
||||
return window['go']['main']['App']['ChatAvailable']();
|
||||
}
|
||||
@@ -886,8 +894,8 @@ export function GetRotatorHeading() {
|
||||
return window['go']['main']['App']['GetRotatorHeading']();
|
||||
}
|
||||
|
||||
export function GetRotatorSettings() {
|
||||
return window['go']['main']['App']['GetRotatorSettings']();
|
||||
export function GetRotators() {
|
||||
return window['go']['main']['App']['GetRotators']();
|
||||
}
|
||||
|
||||
export function GetSPEStatus() {
|
||||
@@ -1162,6 +1170,14 @@ export function 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) {
|
||||
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
||||
}
|
||||
@@ -1362,6 +1378,14 @@ export function 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() {
|
||||
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||
}
|
||||
@@ -1706,8 +1730,8 @@ export function SaveRelayAuto(arg1) {
|
||||
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRotatorSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
||||
export function SaveRotators(arg1) {
|
||||
return window['go']['main']['App']['SaveRotators'](arg1);
|
||||
}
|
||||
|
||||
export function SaveStationDevices(arg1) {
|
||||
@@ -1766,6 +1790,10 @@ export function SendQSORecordingEmail(arg1) {
|
||||
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
||||
}
|
||||
|
||||
export function SetActiveRotor(arg1) {
|
||||
return window['go']['main']['App']['SetActiveRotor'](arg1);
|
||||
}
|
||||
|
||||
export function 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);
|
||||
}
|
||||
|
||||
export function SetKenwoodKeySpeed(arg1) {
|
||||
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
|
||||
}
|
||||
|
||||
export function SetPassphrase(arg1) {
|
||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||
}
|
||||
@@ -1926,6 +1958,10 @@ export function SyncPOTAHunterLog(arg1, arg2) {
|
||||
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function TailLogFile(arg1) {
|
||||
return window['go']['main']['App']['TailLogFile'](arg1);
|
||||
}
|
||||
|
||||
export function TestCloudlogUpload() {
|
||||
return window['go']['main']['App']['TestCloudlogUpload']();
|
||||
}
|
||||
@@ -1966,8 +2002,8 @@ export function TestQRZUpload() {
|
||||
return window['go']['main']['App']['TestQRZUpload']();
|
||||
}
|
||||
|
||||
export function TestRotator(arg1) {
|
||||
return window['go']['main']['App']['TestRotator'](arg1);
|
||||
export function TestRotatorDevice(arg1, arg2) {
|
||||
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function TestStationDevice(arg1) {
|
||||
|
||||
@@ -1442,6 +1442,8 @@ export namespace lotwusers {
|
||||
is_user: boolean;
|
||||
last_upload?: string;
|
||||
days_ago: number;
|
||||
feed_date?: string;
|
||||
feed_stale: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Info(source);
|
||||
@@ -1452,6 +1454,8 @@ export namespace lotwusers {
|
||||
this.is_user = source["is_user"];
|
||||
this.last_upload = source["last_upload"];
|
||||
this.days_ago = source["days_ago"];
|
||||
this.feed_date = source["feed_date"];
|
||||
this.feed_stale = source["feed_stale"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1516,6 +1520,7 @@ export namespace main {
|
||||
transport: string;
|
||||
host: string;
|
||||
port: number;
|
||||
password: string;
|
||||
com_port: string;
|
||||
baud: number;
|
||||
freq_out: boolean;
|
||||
@@ -1536,6 +1541,7 @@ export namespace main {
|
||||
this.transport = source["transport"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.password = source["password"];
|
||||
this.com_port = source["com_port"];
|
||||
this.baud = source["baud"];
|
||||
this.freq_out = source["freq_out"];
|
||||
@@ -1924,6 +1930,7 @@ export namespace main {
|
||||
kenwood_host: string;
|
||||
kenwood_port: string;
|
||||
kenwood_baud: number;
|
||||
kenwood_data_mode: string;
|
||||
yaesu_low_lines: boolean;
|
||||
kenwood_low_lines: boolean;
|
||||
icom_port: string;
|
||||
@@ -1941,6 +1948,9 @@ export namespace main {
|
||||
digital_default: string;
|
||||
share_enabled: boolean;
|
||||
share_port: number;
|
||||
ptt_hotkey_enabled: boolean;
|
||||
ptt_hotkey: string;
|
||||
ptt_hotkey_toggle: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new CATSettings(source);
|
||||
@@ -1966,6 +1976,7 @@ export namespace main {
|
||||
this.kenwood_host = source["kenwood_host"];
|
||||
this.kenwood_port = source["kenwood_port"];
|
||||
this.kenwood_baud = source["kenwood_baud"];
|
||||
this.kenwood_data_mode = source["kenwood_data_mode"];
|
||||
this.yaesu_low_lines = source["yaesu_low_lines"];
|
||||
this.kenwood_low_lines = source["kenwood_low_lines"];
|
||||
this.icom_port = source["icom_port"];
|
||||
@@ -1983,6 +1994,9 @@ export namespace main {
|
||||
this.digital_default = source["digital_default"];
|
||||
this.share_enabled = source["share_enabled"];
|
||||
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 {
|
||||
@@ -2800,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 {
|
||||
enabled: boolean;
|
||||
ok: boolean;
|
||||
azimuth: number;
|
||||
raw: string;
|
||||
rotors: string[];
|
||||
active: number;
|
||||
motorized: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RotatorHeading(source);
|
||||
@@ -2816,34 +2871,9 @@ export namespace main {
|
||||
this.ok = source["ok"];
|
||||
this.azimuth = source["azimuth"];
|
||||
this.raw = source["raw"];
|
||||
}
|
||||
}
|
||||
export class RotatorSettings {
|
||||
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"];
|
||||
this.rotors = source["rotors"];
|
||||
this.active = source["active"];
|
||||
this.motorized = source["motorized"];
|
||||
}
|
||||
}
|
||||
export class ScpStatus {
|
||||
@@ -2904,6 +2934,8 @@ export namespace main {
|
||||
worked_call: boolean;
|
||||
new_county: boolean;
|
||||
new_pota: boolean;
|
||||
new_pfx: boolean;
|
||||
pfx?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SpotStatus(source);
|
||||
@@ -2920,6 +2952,8 @@ export namespace main {
|
||||
this.worked_call = source["worked_call"];
|
||||
this.new_county = source["new_county"];
|
||||
this.new_pota = source["new_pota"];
|
||||
this.new_pfx = source["new_pfx"];
|
||||
this.pfx = source["pfx"];
|
||||
}
|
||||
}
|
||||
export class StartupStatus {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# ACOM built-in ATU — what we know, and why OpsLog does not drive it
|
||||
|
||||
Session of 2026-08-02 on F4BPO's friend's **ACOM 500S**, serial link, telemetry
|
||||
trace. Written down so nobody repeats the work. **Conclusion first: the tuner
|
||||
cannot be started over the serial link, and the attempt to work around that was
|
||||
abandoned.**
|
||||
|
||||
## The tuner is PA state 8
|
||||
|
||||
Frame byte 3's high nibble is the PA status. ACOM's own list (recovered by the
|
||||
ACOM-Controller project) has no entry for 8; it is the antenna tuner. Confirmed
|
||||
twice, front-panel TUNE, with the byte-level trace running:
|
||||
|
||||
```
|
||||
14:47:20.745 state 8 step 0 ← TUNE pressed. err=FF
|
||||
14:47:22.588 operator keys a carrier (FT-891, FM, 31 W, 3.6358 MHz)
|
||||
14:47:24.006 state 8 step 2
|
||||
14:47:25.201 state 8 step 4
|
||||
14:47:27.457 state 8 step 5
|
||||
14:47:30.092 state 5 STANDBY ← accord finished
|
||||
```
|
||||
|
||||
Byte 3's **low** nibble is a step counter through the accord: `0 → 2 → 4 → 5`.
|
||||
Step 0 means *armed, waiting for RF* — in the first of the two runs the
|
||||
amplifier sat there **30 seconds** untouched before the operator transmitted.
|
||||
|
||||
`paStatusNames[8] = "TUNE"` in acom.go is the only thing kept from all of this.
|
||||
|
||||
## The command does not exist in the state-command family
|
||||
|
||||
Every action byte of `55 81 08 02 00 XX 00 CHK` was tried, each one from a
|
||||
**verified** STANDBY (an earlier sweep was worthless because its second code
|
||||
parked the amp in SERVICE, where it silently refuses everything — 17 false
|
||||
negatives). Result:
|
||||
|
||||
| byte | effect |
|
||||
|------|--------|
|
||||
| 0x01, 0x02 | **reboot the amplifier** — it prints `AMPLIFIER: ACOM 500S / HELLO ME…` |
|
||||
| 0x04 | → SERVICE (state 4) |
|
||||
| 0x05 | → STANDBY (state 5) |
|
||||
| 0x06 | → OPERATE (state 6) |
|
||||
| 0x0A | → OFF (state 10) |
|
||||
| 0x03, 0x07..0x09, 0x0B..0x18 | nothing, state unchanged |
|
||||
|
||||
The action byte **is** the target PA state — four independent confirmations.
|
||||
Which makes 0x08 the obvious candidate for TUNE. **It was tried, from a verified
|
||||
STANDBY, and does nothing.** State 8 is reachable from the front panel only.
|
||||
|
||||
Going further would mean varying the other bytes of the frame. That is a large
|
||||
space, and 0x01/0x02 prove that bad values reboot a 500 W amplifier. Not worth
|
||||
it.
|
||||
|
||||
## The workaround that was built and removed
|
||||
|
||||
Since the amp waits, armed, for a carrier, OpsLog could watch for state 8 and
|
||||
supply one: memorise the mode, drop the power, switch to FM, key the PTT, wait
|
||||
for state 8 to clear, unkey, restore. It was implemented (`acomtune.go`, opt-in,
|
||||
Settings → Amplifier) and **removed** after the first hardware test:
|
||||
|
||||
- the power change did not take effect, and
|
||||
- **the PTT was not released when the tune finished.**
|
||||
|
||||
A logger that can leave a transceiver keyed is not acceptable, and the whole
|
||||
thing was scaffolding around a protocol we do not actually control. Removed at
|
||||
the operator's request: *"je veux qu'OpsLog soit solide et ça c'est de la
|
||||
bidouille."*
|
||||
|
||||
If it is ever revisited, the unexplained part is why the tune-finished detection
|
||||
did not fire — `anyAcomTuning()` polled `StateRaw == 8` every 200 ms and should
|
||||
have seen the drop to STANDBY. Suspect the status snapshot, not the amplifier.
|
||||
|
||||
## Loose ends worth knowing
|
||||
|
||||
- **Frame byte 6 is a multiplexer.** The tail of the frame changes meaning with
|
||||
it: `0x93` = bytes 48/49 carry the TX frequency in kHz (`62 1B` = 7010 while
|
||||
the rig was on 7.010 MHz), `0x9D` = something else. Byte 70 looked like a
|
||||
tuner flag for a while purely because of this — it toggles on its own several
|
||||
times a minute. Do not read tail bytes without checking byte 6.
|
||||
- **Byte 5 bit 0x80 alternates frame to frame.** It is a sequence/parity bit,
|
||||
not a command acknowledgement.
|
||||
- **Byte 66 (the error code) shows 0x14 / 0x8E / 0x69 / 0x0F during a normal
|
||||
tune and during transmission.** `errText` renders those as "ERROR — see
|
||||
display" and "Remove drive power", so an ACOM operator probably sees a false
|
||||
alarm. This was never confirmed with the amplifier in OPERATE (it was in
|
||||
STANDBY throughout, where drive genuinely is an error) — worth a look with a
|
||||
clean log before touching the table.
|
||||
@@ -77,9 +77,14 @@ var models = map[string]model{
|
||||
}
|
||||
|
||||
// paStatusNames maps the PAstatus nibble to a display string.
|
||||
// 8 is absent from the state list the ACOM-Controller project recovered; it is
|
||||
// the antenna tuner, read off F4BPO's 500S on 2026-08-02. Pressing TUNE on the
|
||||
// front panel took byte 3 from 0x51 to 0x80, the amp then WAITED 19 s doing
|
||||
// nothing until the operator keyed a carrier, ran the tune over ~8 s while the
|
||||
// low nibble stepped 0→2→4→5, and dropped back to 0x51 (STANDBY).
|
||||
var paStatusNames = map[int]string{
|
||||
1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE",
|
||||
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 9: "SYSTEM", 10: "OFF",
|
||||
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 8: "TUNE", 9: "SYSTEM", 10: "OFF",
|
||||
}
|
||||
|
||||
// acomBands maps the band nibble to a band label.
|
||||
|
||||
@@ -869,3 +869,22 @@ func (m *Manager) YaesuDo(fn func(YaesuController) error) error {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
+21
-6
@@ -61,10 +61,12 @@ type Flex struct {
|
||||
|
||||
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
||||
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
|
||||
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)
|
||||
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
||||
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)
|
||||
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)
|
||||
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||
|
||||
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
||||
@@ -185,7 +187,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
||||
return &Flex{
|
||||
host: strings.TrimSpace(host), port: port,
|
||||
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{},
|
||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||
pinnedSlice: -1,
|
||||
@@ -338,11 +340,17 @@ func (f *Flex) reader(conn net.Conn) {
|
||||
if idx, err := strconv.Atoi(mm[1]); err == nil {
|
||||
f.mu.Lock()
|
||||
call := f.spotCall[idx]
|
||||
mode := f.spotMode[idx]
|
||||
handler := f.OnSpotClick
|
||||
f.mu.Unlock()
|
||||
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)
|
||||
// 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.
|
||||
f.mu.Lock()
|
||||
call, pending := f.pendingSpot[seq]
|
||||
spotMode := f.pendingSpotMode[seq]
|
||||
if pending {
|
||||
delete(f.pendingSpot, seq)
|
||||
delete(f.pendingSpotMode, seq)
|
||||
}
|
||||
if pending && ok && len(parts) >= 3 {
|
||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||
f.spotCall[idx] = call
|
||||
f.spotMode[idx] = spotMode
|
||||
f.spotIdx[idx] = true
|
||||
f.spotByCall[strings.ToUpper(call)] = idx
|
||||
}
|
||||
@@ -801,6 +812,7 @@ func (f *Flex) handleStatus(payload string) {
|
||||
if removed {
|
||||
delete(f.spotIdx, idx)
|
||||
delete(f.spotCall, idx)
|
||||
delete(f.spotMode, idx)
|
||||
} else {
|
||||
f.spotIdx[idx] = true
|
||||
}
|
||||
@@ -1263,6 +1275,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
||||
if hadOld {
|
||||
delete(f.spotByCall, upperCall)
|
||||
delete(f.spotCall, old)
|
||||
delete(f.spotMode, old)
|
||||
delete(f.spotIdx, old)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
@@ -1288,6 +1301,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
||||
// (trigger) can be resolved back to the callsign.
|
||||
f.mu.Lock()
|
||||
f.pendingSpot[seq] = s.Callsign
|
||||
f.pendingSpotMode[seq] = s.Mode
|
||||
f.mu.Unlock()
|
||||
}
|
||||
return nil
|
||||
@@ -1320,6 +1334,7 @@ func (f *Flex) ClearSpots() error {
|
||||
f.mu.Lock()
|
||||
f.spotIdx = map[int]bool{}
|
||||
f.spotCall = map[int]string{}
|
||||
f.spotMode = map[int]string{}
|
||||
f.spotByCall = map[string]int{}
|
||||
connected := f.conn != nil
|
||||
f.mu.Unlock()
|
||||
|
||||
+75
-16
@@ -56,6 +56,12 @@ type Kenwood struct {
|
||||
// a wire, which is how most operators actually put a rig on the network.
|
||||
host string
|
||||
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
|
||||
port serial.Port
|
||||
@@ -70,6 +76,15 @@ type Kenwood struct {
|
||||
curFreq int64
|
||||
curRXFreq int64
|
||||
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.
|
||||
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.
|
||||
split := f.Split
|
||||
if !split {
|
||||
rxv, rxOK := k.askVFO("FR;")
|
||||
txv, txOK := k.askVFO("FT;")
|
||||
if rxOK && txOK && rxv != txv {
|
||||
// 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;")
|
||||
txv, txOK := k.askVFO("FT;")
|
||||
if rxOK && txOK && rxv != txv {
|
||||
k.splitCached = true
|
||||
// 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
|
||||
// as vague about the VFO field. The block below picks the TRANSMIT
|
||||
// VFO as "the other one" from f.VFO, so leaving them disagreeing
|
||||
// would read the transmit frequency off the wrong dial.
|
||||
if rxv == "A" || rxv == "B" {
|
||||
k.splitVFOCached = rxv
|
||||
}
|
||||
}
|
||||
}
|
||||
if k.splitCached {
|
||||
split = true
|
||||
// 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
|
||||
// as vague about the VFO field.
|
||||
// f.VFO too, not just the reported state: the block below picks the
|
||||
// TRANSMIT VFO as "the other one" from f.VFO, and leaving the two
|
||||
// disagreeing would read the transmit frequency off the wrong dial.
|
||||
if rxv == "A" || rxv == "B" {
|
||||
k.curVFO, s.Vfo, f.VFO = rxv, rxv, rxv
|
||||
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 {
|
||||
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)
|
||||
if d == 0 {
|
||||
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))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
@@ -529,11 +587,12 @@ func kenwoodModeDigit(mode string, hz int64) byte {
|
||||
}
|
||||
return '2'
|
||||
}
|
||||
// Any other digital mode rides on the data sideband, which on Kenwood is
|
||||
// plain USB/LSB with the rig's DATA input selected.
|
||||
if hz > 0 && hz < 10_000_000 {
|
||||
return '1'
|
||||
}
|
||||
// Any other digital mode (FT8, PSK, JT…) → USB on every band. There is NO single
|
||||
// mode digit that is right for both families: a K3 wants DATA (MD6), a TS-590SG
|
||||
// wants USB-DATA (MD2 + its DATA modifier), and MD6 on the TS-590SG is FSK/RTTY
|
||||
// — 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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
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 {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
|
||||
@@ -100,8 +100,8 @@ func TestKenwoodModeDigit(t *testing.T) {
|
||||
{"RTTY", 14080000, '6'},
|
||||
{"AM", 7150000, '5'},
|
||||
{"FM", 145000000, '4'},
|
||||
{"FT8", 7074000, '1'}, // data rides on the sideband for the band
|
||||
{"FT8", 14074000, '2'},
|
||||
{"FT8", 7074000, '2'}, // data is ALWAYS USB, even below 10 MHz (K3 "DATA REV" otherwise)
|
||||
{"FT8", 14074000, '2'}, // …and above
|
||||
{"", 14074000, 0}, // nothing to set
|
||||
}
|
||||
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.
|
||||
func TestKenwoodModeToADIF(t *testing.T) {
|
||||
cases := []struct {
|
||||
|
||||
+71
-6
@@ -28,6 +28,12 @@ type TCI struct {
|
||||
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
|
||||
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
|
||||
conn *websocket.Conn
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
color := strings.TrimSpace(s.Color)
|
||||
if color == "" {
|
||||
color = "#FFFFA500" // opaque orange default
|
||||
// TCI's SPOT command wants the colour as a signed 32-bit DECIMAL integer in
|
||||
// 0xAARRGGBB order — NOT a "0x…" hex string (e.g. "spot:UN7GK,cw,14025000,
|
||||
// -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.
|
||||
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.
|
||||
@@ -294,6 +317,48 @@ func (t *TCI) handle(msg string) {
|
||||
if get(0) == "0" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -648,6 +648,18 @@ func parseShowDX(line string) (Spot, bool) {
|
||||
}
|
||||
|
||||
func parseSpot(line string) (Spot, bool) {
|
||||
// A cluster prints its prompt WITHOUT a trailing newline, so the first spot
|
||||
// to arrive gets glued onto it and the line reads
|
||||
// "login: DX de YO2CK-#: 21074.0 PY4PTO FT8 …". The anchor then fails
|
||||
// and the spot is dropped — silently, apart from an UNPARSED log line.
|
||||
//
|
||||
// Cutting everything ahead of "DX de" rather than un-anchoring the pattern:
|
||||
// an un-anchored match would also accept a talk/announce line that merely
|
||||
// QUOTES a spot, and start injecting other people's conversations as real
|
||||
// spots.
|
||||
if i := strings.Index(line, "DX de"); i > 0 {
|
||||
line = line[i:]
|
||||
}
|
||||
m := spotRE.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return Spot{}, false
|
||||
|
||||
@@ -14,6 +14,12 @@ func TestParseSpot(t *testing.T) {
|
||||
`DX de DK0SWL: 14195.5 W1AW CQ Field Day 1745Z`,
|
||||
"W1AW", 14195.5, "20m", "",
|
||||
},
|
||||
{
|
||||
// The cluster prints its prompt with no trailing newline, so the
|
||||
// first spot arrives glued to it. Seen live on 2026-08-02.
|
||||
`login: DX de YO2CK-#: 21074.0 PY4PTO FT8 -16 dB 1733Z`,
|
||||
"PY4PTO", 21074.0, "15m", "",
|
||||
},
|
||||
{
|
||||
`DX de F4XYZ-#: 7074.0 3DA0RU FT8 -10 0823Z JN18`,
|
||||
"3DA0RU", 7074.0, "40m", "JN18",
|
||||
|
||||
@@ -173,6 +173,39 @@ func (m *Manager) EntityNames() []string {
|
||||
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
|
||||
// if nothing loaded).
|
||||
func (m *Manager) Info() ctySource {
|
||||
|
||||
@@ -86,7 +86,13 @@ type Server struct {
|
||||
stopped bool
|
||||
mu sync.Mutex
|
||||
|
||||
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
||||
// WSJT: dial frequency from the last Status, added to Decode audio offsets —
|
||||
// keyed by the WSJT-X instance id, NOT one value per listener. Two instances
|
||||
// share one multicast group, so a single value was overwritten by whichever
|
||||
// sent Status last: decodes from the 50.313 receiver were placed on the
|
||||
// 50.400 panadapter. WSJT-X requires --rig-name for a second instance, so the
|
||||
// id is distinct whenever there is more than one.
|
||||
dialHz map[string]int64
|
||||
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
|
||||
}
|
||||
|
||||
@@ -211,15 +217,21 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
// offsets can be turned into RF frequencies for the panadapter.
|
||||
if w.FreqHz > 0 && !w.IsDecode {
|
||||
s.mu.Lock()
|
||||
s.lastDialHz = w.FreqHz
|
||||
if s.dialHz == nil {
|
||||
s.dialHz = map[string]int64{}
|
||||
}
|
||||
s.dialHz[w.ProgramID] = w.FreqHz
|
||||
s.mu.Unlock()
|
||||
}
|
||||
if w.IsDecode {
|
||||
s.mu.Lock()
|
||||
dial := s.lastDialHz
|
||||
dial := s.dialHz[w.ProgramID]
|
||||
s.mu.Unlock()
|
||||
if dial <= 0 {
|
||||
return // no dial freq yet (Status not seen) → can't place the spot
|
||||
// No Status from THIS instance yet. Guessing with another
|
||||
// instance's dial is what produced the wrong-panadapter spots,
|
||||
// so drop the decode and wait — Status arrives every second.
|
||||
return
|
||||
}
|
||||
ev.DecodeCall = w.DecodeCall
|
||||
ev.DecodeFreqHz = dial + w.DeltaFreqHz
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# QRZ.com XML API — what a free account returns, and what a subscription adds
|
||||
|
||||
Captured 2026-08-02 on F4BPO's two accounts, same callsign looked up twice
|
||||
within a minute. Kept because the difference is the answer to a question that
|
||||
comes back regularly: *"someone told me their logger gets the 6-character
|
||||
locator from QRZ without paying"*. It does not.
|
||||
|
||||
## Free account — `SubExp: non-subscriber`
|
||||
|
||||
```xml
|
||||
<Callsign>
|
||||
<call>F4BPO</call>
|
||||
<fname>Gregory</fname>
|
||||
<name>Salaun</name>
|
||||
<addr2>Veigy-Foncenex</addr2>
|
||||
<country>France</country>
|
||||
</Callsign>
|
||||
<Session>
|
||||
<SubExp>non-subscriber</SubExp>
|
||||
<Message>A subscription is required to access the complete record.</Message>
|
||||
</Session>
|
||||
```
|
||||
|
||||
**Five fields.** No `grid`, no `lat`/`lon`, no `dxcc`, no zones, no email, no
|
||||
street, no image.
|
||||
|
||||
## Subscriber — `SubExp: Sat Jul 3 21:45:38 2027`
|
||||
|
||||
```xml
|
||||
<Callsign>
|
||||
<call>F4BPO</call> <dxcc>227</dxcc> <nickname>Greg</nickname>
|
||||
<fname>Gregory</fname> <name>Salaun</name>
|
||||
<addr2>Veigy-Foncenex</addr2><zip>74140</zip> <country>France</country>
|
||||
<lat>46.103333</lat> <lon>6.290000</lon> <grid>JN36dc</grid>
|
||||
<ccode>97</ccode> <land>France</land> <class>2</class>
|
||||
<codes>E</codes> <qslmgr>Lotw, Buro, Direct</qslmgr>
|
||||
<email>…</email> <u_views>56966</u_views>
|
||||
<bio>4414</bio> <biodate>2026-07-08 15:30:58</biodate>
|
||||
<image>https://cdn-xml.qrz.com/o/f4bpo/F4BPO_Greg_png.jpg</image>
|
||||
<imageinfo>800:1200:221041</imageinfo>
|
||||
<moddate>2025-08-24 19:55:31</moddate>
|
||||
<eqsl>0</eqsl> <mqsl>1</mqsl> <lotw>1</lotw>
|
||||
<cqzone>14</cqzone> <ituzone>27</ituzone>
|
||||
<geoloc>user</geoloc> <name_fmt>Gregory "Greg" Salaun</name_fmt>
|
||||
<serial>1000134</serial>
|
||||
</Callsign>
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
- **A free QRZ account can never fill the locator, the coordinates, the zones
|
||||
or the e-mail.** If someone reports a logger doing it on a free account, they
|
||||
are either using HamQTH (free, returns a 6-character `grid`) or they hold an
|
||||
XML subscription without realising it — the QRZ.com account and the "XML
|
||||
Logbook Data" subscription are sold separately. `SubExp` in the session block
|
||||
settles it in one request.
|
||||
- **Profile edits take a few minutes to reach the XML feed.** Changing the grid
|
||||
on the website and looking the callsign up immediately returns the OLD value,
|
||||
which looks exactly like a stale cache. Re-test after a few minutes before
|
||||
suspecting the logger. (This cost an hour on 2026-08-02.)
|
||||
|
||||
## Fields OpsLog does not use yet
|
||||
|
||||
`qrz.go` maps call, name, addr1/2, zip, state, county, country, grid, lat, lon,
|
||||
dxcc, cqzone, ituzone, cont, email, qslmgr and image. Left on the table:
|
||||
|
||||
- **`geoloc`** — `user` when the operator entered the position by hand, versus a
|
||||
geocoded value derived from the postal address. This is a direct statement of
|
||||
how much the coordinates and the locator can be trusted, and it is exactly
|
||||
the question the grid-precision rule in App.tsx tries to answer by comparing
|
||||
string lengths. Worth wiring if the locator ever misbehaves again.
|
||||
- **`eqsl` / `mqsl` / `lotw`** — the station's QSL preferences as flags, more
|
||||
reliable than parsing the free-text `qslmgr`.
|
||||
- `nickname` / `name_fmt`, `class`, `moddate`, `bio`.
|
||||
@@ -29,6 +29,13 @@ type Info struct {
|
||||
IsUser bool `json:"is_user"`
|
||||
LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD
|
||||
DaysAgo int `json:"days_ago"` // days since last upload (-1 if unknown)
|
||||
// FeedDate is the newest upload date anywhere in ARRL's file, i.e. when ARRL
|
||||
// last regenerated it, and FeedStale says that date is more than two days old.
|
||||
// Without them "last uploaded 7 days ago" is unreadable: it may mean the
|
||||
// station went quiet, or that the FEED has been frozen for a week — which is
|
||||
// exactly what ARRL served on 2026-08-02, its newest line dated 2026-07-27.
|
||||
FeedDate string `json:"feed_date,omitempty"`
|
||||
FeedStale bool `json:"feed_stale"`
|
||||
}
|
||||
|
||||
// Manager holds the parsed list + cache location.
|
||||
@@ -36,6 +43,7 @@ type Manager struct {
|
||||
mu sync.RWMutex
|
||||
users map[string]time.Time // UPPER(callsign) → last-upload date (UTC)
|
||||
updated time.Time // when the cache was last refreshed
|
||||
newest time.Time // newest upload date in the file = when ARRL built it
|
||||
dir string
|
||||
client *http.Client
|
||||
}
|
||||
@@ -100,6 +108,7 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
|
||||
// parse loads the CSV bytes into the map and returns the count.
|
||||
func (m *Manager) parse(data []byte) int {
|
||||
users := make(map[string]time.Time, 1<<20)
|
||||
var newest time.Time
|
||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
@@ -120,16 +129,29 @@ func (m *Manager) parse(data []byte) int {
|
||||
continue
|
||||
}
|
||||
users[call] = t
|
||||
if t.After(newest) {
|
||||
newest = t
|
||||
}
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return 0
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.users = users
|
||||
m.newest = newest
|
||||
m.mu.Unlock()
|
||||
return len(users)
|
||||
}
|
||||
|
||||
// feedInfo fills the feed-age fields shared by every Lookup result.
|
||||
func (m *Manager) feedInfo(i Info) Info {
|
||||
if !m.newest.IsZero() {
|
||||
i.FeedDate = m.newest.Format("2006-01-02")
|
||||
i.FeedStale = time.Since(m.newest) > 48*time.Hour
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// Lookup reports whether callsign is a LoTW user and how long ago it uploaded.
|
||||
// Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P")
|
||||
// the longest slash-separated segment — the base call LoTW is keyed on.
|
||||
@@ -154,13 +176,13 @@ func (m *Manager) Lookup(callsign string) Info {
|
||||
t, ok = m.users[base]
|
||||
}
|
||||
if !ok {
|
||||
return Info{DaysAgo: -1}
|
||||
return m.feedInfo(Info{DaysAgo: -1})
|
||||
}
|
||||
days := int(time.Since(t).Hours() / 24)
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
return Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days}
|
||||
return m.feedInfo(Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days})
|
||||
}
|
||||
|
||||
// Count returns how many callsigns are loaded.
|
||||
|
||||
@@ -24,7 +24,13 @@ const (
|
||||
defaultPort = 9008
|
||||
dialTimeout = 5 * 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
|
||||
)
|
||||
|
||||
@@ -50,8 +56,9 @@ type Status struct {
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
host string
|
||||
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
|
||||
conn net.Conn
|
||||
@@ -70,11 +77,11 @@ type Client struct {
|
||||
running bool
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
func New(host string, port int, password string) *Client {
|
||||
if port <= 0 || port > 65535 {
|
||||
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 {
|
||||
@@ -118,9 +125,15 @@ func (c *Client) SetFanMode(mode string) error {
|
||||
default:
|
||||
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
|
||||
}
|
||||
applog.Printf("pgxl: set fanmode=%s reply=%q", m, reply)
|
||||
c.statusMu.Lock()
|
||||
c.status.FanMode = m // optimistic
|
||||
c.fanPending, c.fanPendingAt = m, time.Now()
|
||||
@@ -177,12 +190,62 @@ func (c *Client) ensureConnected() error {
|
||||
}
|
||||
c.conn = 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))
|
||||
_, _ = 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
|
||||
}
|
||||
|
||||
// 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() {
|
||||
c.mu.Lock()
|
||||
if c.conn != nil {
|
||||
@@ -237,9 +300,10 @@ func (c *Client) parse(resp string) {
|
||||
c.statusMu.Lock()
|
||||
c.status.Connected = true
|
||||
c.status.LastError = ""
|
||||
// One raw frame per session is enough to learn the field set — the frames
|
||||
// carry live meter values, so "log on change" logged every frame.
|
||||
if c.lastRaw == "" {
|
||||
// Log the first REAL status frame (one that carries "key=value" fields) so the
|
||||
// field set is visible — even if an earlier reply was junk like "Unauthorized"
|
||||
// (which would otherwise latch lastRaw and hide the real frame).
|
||||
if strings.Contains(data, "=") && !strings.Contains(c.lastRaw, "=") {
|
||||
c.lastRaw = 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()
|
||||
}
|
||||
|
||||
// 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:
|
||||
// - by exact callsign → shown as "this call worked N×"
|
||||
// - 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()
|
||||
}
|
||||
|
||||
// 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
|
||||
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks
|
||||
// (an n-fer); each is added separately.
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
// - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss}
|
||||
// (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with
|
||||
// <relay1>..<relay8> (relay0 is reserved). Optional HTTP basic auth.
|
||||
// - Dingtian IOT relay (DTWONDER) — 2/4/8/16/24/32 relays over its HTTP GET
|
||||
// CGI. Control: GET /relay_cgi.cgi?type=0&relay=N&on=1&time=0&pwd=P&;
|
||||
// status: GET /relay_cgi_load.cgi. Both answer &-separated fields.
|
||||
// (IOT Relay Programming Manual V1.9.8.1, §3.)
|
||||
//
|
||||
// A Device presents the same surface to the app regardless of wire protocol.
|
||||
package relaydev
|
||||
@@ -173,3 +177,119 @@ func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── Dingtian IOT relay (DTWONDER) ──────────────────────────────────────
|
||||
//
|
||||
// The board speaks several protocols (Modbus, MQTT, CoAP, its own binary); we
|
||||
// use the HTTP GET CGI, which needs no connection state and matches how the
|
||||
// other network boards here are driven.
|
||||
//
|
||||
// Both endpoints answer &-separated fields, first one 0 on success:
|
||||
//
|
||||
// /relay_cgi_load.cgi → &0&4&1&0&1&0&
|
||||
// result, count, r1…rN
|
||||
// /relay_cgi.cgi?type=0&relay=0&on=1&time=0&pwd=0& → &0&0&0&1&0&
|
||||
// result, type, relay, on, time
|
||||
//
|
||||
// NOTE the relay index in the URL is ZERO-based (relay=0 is relay 1), while the
|
||||
// Device interface is 1-based like every other board here.
|
||||
type dingtian struct {
|
||||
host string
|
||||
session string // optional: the board can require "Cookie: session=<id>"
|
||||
pwd string // CGI password, 0–9999; "0" (or blank) when none is set
|
||||
count int
|
||||
}
|
||||
|
||||
// NewDingtian builds a Dingtian IOT relay client. session is the HTTP session ID
|
||||
// when the board has "HTTP Session" enabled (blank otherwise); pwd is its relay
|
||||
// password (blank or "0" when none).
|
||||
func NewDingtian(host, session, pwd string, count int) Device {
|
||||
if count <= 0 {
|
||||
count = 2
|
||||
}
|
||||
if strings.TrimSpace(pwd) == "" {
|
||||
pwd = "0"
|
||||
}
|
||||
return &dingtian{host: host, session: strings.TrimSpace(session), pwd: strings.TrimSpace(pwd), count: count}
|
||||
}
|
||||
|
||||
func (d *dingtian) Count() int { return d.count }
|
||||
func (d *dingtian) Close() error { return nil } // stateless HTTP, nothing to release
|
||||
|
||||
// getCGI issues the GET with the session cookie the board may require.
|
||||
func (d *dingtian) getCGI(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.session != "" {
|
||||
req.Header.Set("Cookie", "session="+d.session)
|
||||
}
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// dtFields splits an &-separated CGI reply into its fields, dropping the empty
|
||||
// ones the leading and trailing "&" produce.
|
||||
func dtFields(body []byte) []string {
|
||||
var out []string
|
||||
for _, f := range strings.Split(strings.TrimSpace(string(body)), "&") {
|
||||
if f = strings.TrimSpace(f); f != "" {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (d *dingtian) Set(ctx context.Context, relay int, on bool) error {
|
||||
if relay < 1 || relay > d.count {
|
||||
return fmt.Errorf("relay %d out of range 1..%d", relay, d.count)
|
||||
}
|
||||
state := 0
|
||||
if on {
|
||||
state = 1
|
||||
}
|
||||
// type=0 is plain ON/OFF (1 = jogging, 2 = delay, 3 = flash, 4 = toggle),
|
||||
// and time is then unused.
|
||||
body, err := d.getCGI(ctx, fmt.Sprintf("http://%s/relay_cgi.cgi?type=0&relay=%d&on=%d&time=0&pwd=%s&",
|
||||
d.host, relay-1, state, d.pwd))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A wrong password or session answers 200 with a non-zero result (e.g.
|
||||
// "&302&/&"), so the HTTP status alone does not tell us it worked.
|
||||
if f := dtFields(body); len(f) == 0 || f[0] != "0" {
|
||||
return fmt.Errorf("dingtian: refused (%s) — check the relay password and the HTTP session ID",
|
||||
strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dingtian) Status(ctx context.Context) ([]bool, error) {
|
||||
body, err := d.getCGI(ctx, fmt.Sprintf("http://%s/relay_cgi_load.cgi", d.host))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := dtFields(body)
|
||||
if len(f) < 2 || f[0] != "0" {
|
||||
return nil, fmt.Errorf("dingtian: bad status reply %q", strings.TrimSpace(string(body)))
|
||||
}
|
||||
// The board reports its own relay count; trust it over the configured one so
|
||||
// a mis-set channel count doesn't silently hide relays.
|
||||
if n, e := strconv.Atoi(f[1]); e == nil && n > 0 && n <= 32 {
|
||||
d.count = n
|
||||
}
|
||||
out := make([]bool, d.count)
|
||||
for i := 0; i < d.count && i+2 < len(f); i++ {
|
||||
out[i] = f[i+2] == "1"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -99,3 +99,76 @@ func TestKMTronicSetURL(t *testing.T) {
|
||||
}
|
||||
_ = d.Set(context.Background(), 1, false) // → /FF0100
|
||||
}
|
||||
|
||||
// Dingtian — the wire examples come straight from the IOT Relay Programming
|
||||
// Manual V1.9.8.1 §3.1/§3.2. The two traps pinned here: the URL relay index is
|
||||
// ZERO-based while the Device interface is 1-based, and a refusal (bad password
|
||||
// or session) answers HTTP 200 with a non-zero first field.
|
||||
|
||||
func TestDingtianStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/relay_cgi_load.cgi" {
|
||||
t.Errorf("unexpected status path %q", r.URL.Path)
|
||||
}
|
||||
// Manual's own example: ok, 4 relays, 1 on, 2 off, 3 on, 4 off.
|
||||
_, _ = w.Write([]byte("&0&4&1&0&1&0&"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2)
|
||||
st, err := d.Status(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The board said 4 relays even though 2 were configured — its count wins.
|
||||
want := []bool{true, false, true, false}
|
||||
if len(st) != len(want) {
|
||||
t.Fatalf("got %d relays, want %d", len(st), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if st[i] != want[i] {
|
||||
t.Errorf("relay %d = %v, want %v", i+1, st[i], want[i])
|
||||
}
|
||||
}
|
||||
if d.Count() != 4 {
|
||||
t.Errorf("Count() = %d, want 4 (taken from the board)", d.Count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingtianSetUsesZeroBasedIndexAndSession(t *testing.T) {
|
||||
var gotQuery, gotCookie string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQuery = r.URL.RawQuery
|
||||
gotCookie = r.Header.Get("Cookie")
|
||||
_, _ = w.Write([]byte("&0&0&2&1&0&"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "12345678", "4660", 4)
|
||||
if err := d.Set(context.Background(), 3, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "relay=2") {
|
||||
t.Errorf("relay 3 must go out as relay=2 (zero-based); query was %q", gotQuery)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "on=1") || !strings.Contains(gotQuery, "type=0") ||
|
||||
!strings.Contains(gotQuery, "pwd=4660") {
|
||||
t.Errorf("unexpected query %q", gotQuery)
|
||||
}
|
||||
if gotCookie != "session=12345678" {
|
||||
t.Errorf("session cookie = %q, want session=12345678", gotCookie)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingtianSetRefusalIsAnError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
// Manual §3.4.4: a bad session still answers 200 OK.
|
||||
_, _ = w.Write([]byte("&302&/&"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2)
|
||||
if err := d.Set(context.Background(), 1, true); err == nil {
|
||||
t.Fatal("a refused command answered HTTP 200 and was reported as success")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -59,6 +60,13 @@ type Server struct {
|
||||
ln net.Listener
|
||||
conns map[net.Conn]struct{}
|
||||
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 {
|
||||
@@ -228,10 +236,12 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
||||
return rprt(0), false
|
||||
|
||||
case "t", "\\get_ptt":
|
||||
// We do not read PTT back from every backend, and answering "transmitting"
|
||||
// wrongly would make a client hold off for ever. Reporting RX is the safe
|
||||
// direction: the worst case is a client that transmits when we said it
|
||||
// could, which is what it was going to do anyway.
|
||||
// Echo the last commanded PTT state. WSJT-X/JTDX poll this WHILE
|
||||
// transmitting to confirm the rig is keyed; answering a blanket "0" (RX)
|
||||
// made them decide PTT had failed and abort the over after ~1-2 s.
|
||||
if s.ptt.Load() {
|
||||
return "1\n", false
|
||||
}
|
||||
return "0\n", false
|
||||
case "T", "\\set_ptt":
|
||||
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)
|
||||
return rprt(-9), false
|
||||
}
|
||||
s.ptt.Store(on)
|
||||
return rprt(0), false
|
||||
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -228,6 +228,18 @@ func (c *Client) PowerOn() error {
|
||||
func (c *Client) PowerOff() error {
|
||||
err := c.sendCmd(cmdOff)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+114
-37
@@ -23,6 +23,12 @@ const (
|
||||
ubReadTimeout = 4 * time.Second // was 1s — too tight for a remote link
|
||||
ubKeepAlive = 15 * time.Second // OS-level TCP keepalive
|
||||
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
|
||||
@@ -82,8 +88,22 @@ type Client struct {
|
||||
// yet (Frequency==0) — otherwise the deadband is bypassed and every small QSY
|
||||
// re-tunes the motors.
|
||||
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,
|
||||
// or 0 if none yet.
|
||||
func (c *Client) LastSetKHz() int {
|
||||
@@ -221,12 +241,30 @@ func (c *Client) pollLoop() {
|
||||
}
|
||||
|
||||
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 time.Since(c.pendingDirAt) > 4*time.Second || status.Direction == c.pendingDir {
|
||||
c.pendingDirSet = false
|
||||
} else {
|
||||
status.Direction = c.pendingDir
|
||||
if status.MotorsMoving != 0 {
|
||||
c.pendingDirAt = time.Now() // still repositioning — don't start the grace timer
|
||||
}
|
||||
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
|
||||
@@ -246,7 +284,19 @@ func (c *Client) GetStatus() (*Status, error) {
|
||||
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
|
||||
@@ -277,10 +327,9 @@ func quoteByte(b byte) []byte {
|
||||
return []byte{b}
|
||||
}
|
||||
|
||||
// buildPacket creates a complete packet with checksum and escaping
|
||||
func (c *Client) buildPacket(cmd byte, data []byte) []byte {
|
||||
seq := c.getNextSeq()
|
||||
|
||||
// buildPacket creates a complete packet with checksum and escaping. The seq is
|
||||
// supplied by the caller so sendCommand can match the reply against it.
|
||||
func (c *Client) buildPacket(seq, cmd byte, data []byte) []byte {
|
||||
// Calculate checksum on unquoted data
|
||||
payload := append([]byte{seq, cmd}, data...)
|
||||
chk := calculateChecksum(payload)
|
||||
@@ -365,39 +414,27 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
// Build and send packet
|
||||
packet := c.buildPacket(cmd, data)
|
||||
// Flush anything already sitting in the stream before we send. The antenna
|
||||
// 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)
|
||||
if err != nil {
|
||||
seq := c.getNextSeq()
|
||||
packet := c.buildPacket(seq, cmd, data)
|
||||
if _, err := c.conn.Write(packet); err != nil {
|
||||
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))
|
||||
|
||||
// Read until we get a complete packet
|
||||
var buffer []byte
|
||||
for {
|
||||
b, err := c.reader.ReadByte()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read: %w", 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")
|
||||
}
|
||||
buffer, err := c.readPacket()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse reply
|
||||
_, replyCmd, payload, err := parsePacket(buffer)
|
||||
if err != nil {
|
||||
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)
|
||||
func (c *Client) queryStatus() (*Status, error) {
|
||||
reply, err := c.sendCommand(CMD_STATUS, nil)
|
||||
@@ -513,6 +589,7 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||
c.statusMu.Lock()
|
||||
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
|
||||
c.lastSetKHz = freqKhz
|
||||
c.moveCmdAt = time.Now() // start reporting motion at once — see ubMoveOptimisticWindow
|
||||
if c.lastStatus != nil {
|
||||
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 (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.22.9"
|
||||
appVersion = "0.23.5"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
@@ -57,10 +57,19 @@ Configure the host / password in Settings → Antenna Genius.
|
||||
|
||||
## Relay boards (Station Control)
|
||||
|
||||
USB relay boards for antenna/accessory switching, with named buttons in Station
|
||||
Control: **Denkovi** (4/8 relays, FT245) and generic **CH340 / LCUS**
|
||||
USB-serial boards. A Test-connection button and detection feedback live in the
|
||||
setup panel.
|
||||
Relay boards for antenna/accessory switching, with named buttons in Station
|
||||
Control:
|
||||
|
||||
- **WebSwitch 1216H** (5 relays, LAN) and **KMTronic** 8-relay WEB (LAN, with
|
||||
optional HTTP authentication).
|
||||
- **Dingtian IOT relay** (2 to 32 relays, LAN or WiFi), driven over its HTTP
|
||||
CGI. Give its IP address and pick the relay count; the **relay password** and
|
||||
**session ID** fields are only needed if you enabled them on the board's own
|
||||
web page — a factory board needs neither.
|
||||
- **Denkovi** USB (4/8 relays, FT245) and generic **CH340 / LCUS** USB-serial
|
||||
boards.
|
||||
|
||||
A Test-connection button and detection feedback live in the setup panel.
|
||||
|
||||
## Station Control
|
||||
|
||||
|
||||
Reference in New Issue
Block a user