Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3da1d71323 | ||
|
|
72f692aa04 | ||
|
|
f6cd6e999a | ||
|
|
296a4a55c0 | ||
|
|
acf73c73c7 | ||
|
|
5777c119cb | ||
|
|
85bf0da006 | ||
|
|
b6ea07e3a3 | ||
|
|
549adc8c0e | ||
|
|
0e1e7d9f3c | ||
|
|
3dd428d748 | ||
|
|
c62d992ad6 | ||
|
|
a83acb0f9a | ||
|
|
d8b7b86eb7 | ||
|
|
3e1c36d186 | ||
|
|
6255a9f218 | ||
|
|
599621a998 | ||
|
|
d7bcf21203 | ||
|
|
589e0b9b6a | ||
|
|
c21d6cc23a | ||
|
|
ae21ddb9d7 | ||
|
|
a4623e9ea3 | ||
|
|
06e34372ed | ||
|
|
d23c4c4037 | ||
|
|
d4bfd30636 | ||
|
|
258fa717f1 | ||
|
|
aa995c250c | ||
|
|
72fee7a090 | ||
|
|
ed67ed7fe3 | ||
|
|
fddb3c45c4 | ||
|
|
da1f3eb2bd | ||
|
|
5e0bb6e68e | ||
|
|
2b61f43780 | ||
|
|
2b66e5bc7f | ||
|
|
84b06ed47b | ||
|
|
39bd1ff414 | ||
|
|
6d54100bc9 | ||
|
|
4fe5811fd2 | ||
|
|
d42e6669e1 | ||
|
|
c14353a399 | ||
|
|
734931cf54 | ||
|
|
cad4ec4b72 | ||
|
|
5623b860c2 | ||
|
|
3e22c4d1a3 | ||
|
|
d90f953df4 | ||
|
|
e4217da010 | ||
|
|
c5ac945de0 | ||
|
|
eb40991482 | ||
|
|
781cf636ea | ||
|
|
62b6fe0a3a | ||
|
|
bd2a8524dc | ||
|
|
4afd7dda90 | ||
|
|
ed930667a1 | ||
|
|
311479c52f | ||
|
|
b25efabab8 | ||
|
|
a93f52d2b9 | ||
|
|
c9f7279a01 | ||
|
|
2560fced87 | ||
|
|
1718bf6f33 | ||
|
|
c4c5db3921 | ||
|
|
a1ceea978b | ||
|
|
f4956a63bb | ||
|
|
5a4ad800b3 | ||
|
|
e152ef0ee0 | ||
|
|
b6465ddc9d | ||
|
|
961357474d | ||
|
|
702ca4f0c9 | ||
|
|
93b4964b3b | ||
|
|
14c57035e1 | ||
|
|
7a7fa62af7 | ||
|
|
6ee4c430c7 | ||
|
|
0df6370c59 | ||
|
|
fa94657619 | ||
|
|
f5b74856c6 | ||
|
|
d42fdab85c | ||
|
|
83a8708d60 | ||
|
|
c0671bfe2e | ||
|
|
ec0cb95de9 | ||
|
|
9d691343cf | ||
|
|
c6088bb2de | ||
|
|
069728ac68 | ||
|
|
f221c0ee1e | ||
|
|
013492adb0 | ||
|
|
3a87b58ba3 | ||
|
|
e6b8a17772 | ||
|
|
802c6e8c43 | ||
|
|
f82631690a | ||
|
|
3b58e39eec | ||
|
|
45e3f3edb8 | ||
|
|
c931d8a762 | ||
|
|
eb271e8f20 | ||
|
|
e2d2485703 |
+206
@@ -1,4 +1,210 @@
|
||||
[
|
||||
{
|
||||
"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": "",
|
||||
"en": [
|
||||
"Awards: the CW / Phone / Digital filter now applies to the whole award — bands, counts, confirmations and the contacts behind a cell — instead of only hiding rows from the reference list.",
|
||||
"Yaesu and Kenwood: a new option lowers the DTR and RTS lines on connect, for interfaces that read either as PTT and keep the radio transmitting while OpsLog is open. Off by default — lowering them stops other interfaces transmitting at all.",
|
||||
"SPE amplifiers: the ON and OFF buttons now switch the amplifier on and off over USB.",
|
||||
"Quieter diagnostic log: no more FlexRadio meter subscriptions, per-packet WSJT-X/MSHV lines, or repeated Tuner Genius / PGXL / SPE status frames.",
|
||||
"Station Control: the cards now line up on a grid — same width, and same height across a row — and the column selector goes up to 6.",
|
||||
"New amplifier widget beside the keyers (flame icon): Operate, power ON/OFF, the SPE power level and live watts / SWR / temperature. With several amplifiers a dropdown on the icon picks the one to watch, or all of them.",
|
||||
"Icom: the meters and front-panel readings are polled only while the Icom console is on screen. They were half of the CI-V traffic at all times, which matters when the link is shared — a microHAM Router splitting one CI-V port between OpsLog and WSJT-X, for instance.",
|
||||
"Icom: the power-ON button sends a wake preamble sized for the CI-V speed. It was a fixed length that only suited 19200 baud, so a radio set faster — 115200 is the default — ignored the command.",
|
||||
"The Tuner Genius power meter now shows the peak the device itself measured, instead of whatever instant the poll happened to catch — a kilowatt transmission could read a few hundred watts. Every power meter also clears the moment the carrier drops, instead of trailing several seconds behind it."
|
||||
],
|
||||
"fr": [
|
||||
"Diplômes : le filtre CW / Phonie / Numérique s’applique désormais à tout le diplôme — bandes, comptes, confirmations et QSO derrière une case — au lieu de masquer seulement des lignes de la liste des références.",
|
||||
"Yaesu et Kenwood : une option abaisse les lignes DTR et RTS à la connexion, pour les interfaces qui les lisent comme PTT et laissent la radio en émission tant qu’OpsLog est ouvert. Désactivée par défaut — les abaisser empêche d’autres interfaces d’émettre.",
|
||||
"Amplificateurs SPE : les boutons ON et OFF allument et éteignent désormais l’amplificateur par l’USB.",
|
||||
"Journal de diagnostic allégé : plus d’abonnements aux mètres FlexRadio, de ligne par paquet WSJT-X/MSHV, ni de trames statut Tuner Genius / PGXL / SPE répétées.",
|
||||
"Contrôle station : les cartes s’alignent désormais sur une grille — même largeur, et même hauteur sur une ligne — et le sélecteur de colonnes va jusqu’à 6.",
|
||||
"Nouveau widget amplificateur à côté des manipulateurs (icône flamme) : Operate, marche/arrêt, le niveau de puissance SPE et les watts / ROS / température en direct. Avec plusieurs amplis, une liste déroulante sur l’icône choisit celui à surveiller, ou tous.",
|
||||
"Icom : les mesures et les réglages de façade ne sont interrogés que lorsque la console Icom est affichée. Ils représentaient la moitié du trafic CI-V en permanence, ce qui compte quand la liaison est partagée — un Router microHAM qui répartit un port CI-V entre OpsLog et WSJT-X, par exemple.",
|
||||
"Icom : le bouton de mise sous tension envoie un préambule de réveil dimensionné pour la vitesse CI-V. Sa longueur était fixe et ne convenait qu’à 19200 bauds, si bien qu’une radio réglée plus vite — 115200 par défaut — ignorait la commande.",
|
||||
"Le mètre de puissance du Tuner Genius affiche désormais la crête mesurée par l’appareil lui-même, au lieu de l’instant capté par l’interrogation — une émission d’un kilowatt pouvait afficher quelques centaines de watts. Tous les mètres de puissance retombent aussi dès la fin de l’émission, au lieu de traîner plusieurs secondes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.8",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Main tab: drag the divider between the two panes to resize them. The setting is remembered and travels with the data folder; double-click the divider to even them out.",
|
||||
"Kenwood: bytes arriving glued to the previous answer are no longer discarded, and the link starts from a clean buffer — a rig whose replies ran together could end up one answer behind and never report connected.",
|
||||
"The CAT log line now carries the error explaining a disconnection.",
|
||||
"Kenwood and Yaesu CAT connect again: 0.22.7 lowered the DTR and RTS lines on those ports, which stops many USB-serial interfaces from transmitting. Only Xiegu, where the problem was reported, still does it.",
|
||||
"Kenwood: a port that opens but sends nothing is now reported differently from one sending data that does not answer, and the error quotes what arrived."
|
||||
],
|
||||
"fr": [
|
||||
"Onglet Principal : faites glisser le séparateur entre les deux volets pour les redimensionner. Le réglage est mémorisé et suit le dossier de données ; double-clic pour les égaliser.",
|
||||
"Kenwood : les octets reçus collés à la réponse précédente ne sont plus jetés, et la liaison démarre sur un tampon vide — une radio dont les réponses s’enchaînent pouvait rester une réponse en retard et ne jamais apparaître connectée.",
|
||||
"La ligne de journal CAT indique désormais l’erreur expliquant une déconnexion.",
|
||||
"Le CAT Kenwood et Yaesu se connecte de nouveau : la 0.22.7 abaissait les lignes DTR et RTS sur ces ports, ce qui empêche beaucoup d’interfaces USB-série d’émettre. Seul Xiegu, où le problème avait été signalé, le fait encore.",
|
||||
"Kenwood : un port qui s’ouvre sans rien émettre est désormais distingué d’un port qui émet sans répondre, et l’erreur cite ce qui est arrivé."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.7",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Serial CAT no longer keys the radio just by connecting: DTR and RTS are dropped on Xiegu, Yaesu and Kenwood as they already were on Icom — a Xiegu G90 behind a DE-19 went straight into transmit and stayed there.",
|
||||
"Xiegu: choose how the rig is keyed — CI-V command, RTS or DTR. A G90 does not transmit on the CI-V command, so this is also what lets WSJT-X transmit through the shared CAT link.",
|
||||
"A reference you assign by hand now replaces what the matcher found instead of being added to it, so a correction survives the next recompute.",
|
||||
"Awards: an award can be marked one reference per QSO. When several references then match the same contact it keeps none and lists them, so you pick — a contact does not silently get the wrong DOK. Off by default, so an n-fer POTA activation still counts every park.",
|
||||
"Dark themes: the calendar icon of date and date-time fields is visible again.",
|
||||
"Live award detection now sees the QTH, name, country, comment, note and grid you typed, not just the looked-up address — an award matching on the town stayed silent until the QSO was logged.",
|
||||
"Info (F2) panel: the fields are sized for what they hold — a wider county, narrower prefix and zones, and the address takes the space left by the bearing and distances.",
|
||||
"Clicking in the lower half no longer draws an orange rule under the tab strip.",
|
||||
"A green NEW badge appears on the County field in Info (F2) when the contacted US county has never been worked.",
|
||||
"DARC DOK award updated to v2 in the catalogue: one reference per QSO."
|
||||
],
|
||||
"fr": [
|
||||
"Le CAT série ne met plus la radio en émission par le seul fait de se connecter : DTR et RTS sont abaissés sur Xiegu, Yaesu et Kenwood comme ils l'étaient déjà sur Icom — un Xiegu G90 derrière un DE-19 partait en émission et y restait.",
|
||||
"Xiegu : choix du passage en émission — commande CI-V, RTS ou DTR. Un G90 n'émet pas sur la commande CI-V, c'est donc aussi ce qui permet à WSJT-X d'émettre via le partage CAT.",
|
||||
"Une référence que vous affectez à la main remplace désormais ce que la détection avait trouvé au lieu de s'y ajouter : une correction survit au recalcul suivant.",
|
||||
"Diplômes : un diplôme peut être marqué une seule référence par QSO. Si plusieurs références correspondent alors au même contact, aucune n’est retenue et elles sont listées pour que vous choisissiez — un contact ne reçoit plus silencieusement le mauvais DOK. Désactivé par défaut, donc une activation POTA n-fer compte toujours tous les parcs.",
|
||||
"Thèmes sombres : l’icône calendrier des champs date et date-heure est de nouveau visible.",
|
||||
"La détection des diplômes en direct voit maintenant le QTH, le nom, le pays, le commentaire, la note et le locator saisis, et plus seulement l’adresse issue de la recherche — un diplôme qui matche sur la ville restait muet jusqu’à l’enregistrement du QSO.",
|
||||
"Panneau Info (F2) : les champs sont dimensionnés selon leur contenu — comté plus large, préfixe et zones plus étroits, et l’adresse récupère la place laissée par l’azimut et les distances.",
|
||||
"Un clic dans la moitié basse ne trace plus de trait orange sous la barre d’onglets.",
|
||||
"Un badge vert NOUV apparaît sur le champ Comté dans Info (F2) quand le comté américain contacté n’a jamais été travaillé.",
|
||||
"Diplôme DARC DOK mis à jour en v2 dans le catalogue : une seule référence par QSO."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.6",
|
||||
"date": "",
|
||||
"en": [
|
||||
"A playback that fails to start now says so in the log instead of ending silently after a tenth of a second.",
|
||||
"Replaying a recording before the previous one has finished now works: the new playback waits for the audio device to be free instead of keying the radio and releasing at once.",
|
||||
"The play button becomes a stop button while the recording is going out: it cuts the transmission and releases the PTT, and you can send it again straight away.",
|
||||
"The \"From radio\" and mic levels take effect as you drag the slider, and the RX level now applies to what you HEAR through OpsLog, not only to recordings.",
|
||||
"The padlocks on frequency, band, mode and times no longer flicker under the pointer and refuse the click.",
|
||||
"The callsign field is wider, taking the room from the RST pair.",
|
||||
"MP3 recordings no longer carry a hiss of their own: the 16→32 kHz conversion mirrored the whole spectrum only 7 dB down. It is now 57 dB down.",
|
||||
"Replaying a QSO recording on the air has its own level, separate from the voice keyer: a recording comes from a receiver line output and needs far less gain than a message spoken into a microphone.",
|
||||
"A quiet DX cluster no longer looks like a broken one: silence stopped counting as a disconnection, so a node with few spots keeps its session, its login and its filters."
|
||||
],
|
||||
"fr": [
|
||||
"Une lecture qui ne démarre pas le signale désormais dans le journal, au lieu de s'arrêter en silence au bout d'un dixième de seconde.",
|
||||
"Relancer un enregistrement avant la fin du précédent fonctionne désormais : la nouvelle lecture attend que le périphérique audio soit libre, au lieu de passer en émission et de relâcher aussitôt.",
|
||||
"Le bouton de lecture devient un bouton d'arrêt pendant l'émission de l'enregistrement : il coupe l'émission et relâche le PTT, et vous pouvez le renvoyer aussitôt.",
|
||||
"Les niveaux « From radio » et micro agissent pendant que vous déplacez le curseur, et le niveau RX s'applique désormais à ce que vous ENTENDEZ dans OpsLog, pas seulement aux enregistrements.",
|
||||
"Les cadenas de fréquence, bande, mode et heures ne scintillent plus sous le curseur et acceptent le clic.",
|
||||
"Le champ indicatif est plus large, la place venant de la paire RST.",
|
||||
"Les enregistrements MP3 ne portent plus un souffle qui leur est propre : la conversion 16→32 kHz recopiait tout le spectre à seulement 7 dB en dessous. Il est désormais à 57 dB.",
|
||||
"La relecture d'un enregistrement de QSO a son propre niveau, distinct du manipulateur vocal : une prise vient de la sortie ligne d'un récepteur et demande bien moins de gain qu'un message dit au micro.",
|
||||
"Un cluster calme ne passe plus pour un cluster mort : le silence ne compte plus comme une déconnexion, donc un nœud avec peu de spots garde sa session, son login et ses filtres."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.5",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Cluster filters gain NEW POTA and NEW COUNTY, alongside the existing status chips."
|
||||
],
|
||||
"fr": [
|
||||
"Les filtres du cluster gagnent NEW POTA et NEW COUNTY, à côté des pastilles de statut existantes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.4",
|
||||
"date": "",
|
||||
"en": [
|
||||
"FlexRadio: in split, OpsLog stays on the receive slice instead of following the transmitter.",
|
||||
"QSO recording works on CW again (no sidetone).",
|
||||
"Playing a recording on the air now says why when it cannot.",
|
||||
"The play-on-air button is hidden on CW.",
|
||||
"CW decoder: a station replying at a different speed no longer decodes as a run of dashes, and callsigns are no longer split into single letters by a wide fist.",
|
||||
"FlexRadio: one table per band for the antennas and the TX power per mode class (phone / CW / digital). Antennas follow the band, power follows the band and the mode; an empty box leaves the power alone.",
|
||||
"Grouping the digital modes into one slot now takes effect on the spots already on screen, instead of only on those arriving afterwards.",
|
||||
"The QSO right-click menu no longer runs off the bottom of the window: it opens above the cursor when there is not enough room below.",
|
||||
"QRZ.com confirmations: only QRZ own confirmed marker counts now. The download also clears QSOs an earlier version marked confirmed when QRZ says they are not — run it once to correct your log.",
|
||||
"Awards: a mode filter (All / CW / Phone / Digital) that stacks with the worked/confirmed one — so \"worked on CW but not confirmed on CW\" is one click."
|
||||
],
|
||||
"fr": [
|
||||
"FlexRadio : en split, OpsLog reste sur la slice de réception au lieu de suivre l'émission.",
|
||||
"L'enregistrement des QSO fonctionne à nouveau en CW (sans le signal d'écoute).",
|
||||
"L'émission d'un enregistrement indique désormais pourquoi elle échoue.",
|
||||
"Le bouton d'émission de l'enregistrement est masqué en CW.",
|
||||
"Décodeur CW : une station qui répond à une autre vitesse ne se décode plus en série de traits, et les indicatifs ne sont plus coupés lettre par lettre par un espacement large.",
|
||||
"FlexRadio : un seul tableau par bande pour les antennes et la puissance d'émission par type de mode (phonie / CW / numérique). Les antennes suivent la bande, la puissance suit la bande et le mode ; une case vide ne touche pas à la puissance.",
|
||||
"Le regroupement des modes numériques en un seul créneau s'applique désormais aux spots déjà affichés, et non plus seulement à ceux qui arrivent ensuite.",
|
||||
"Le menu contextuel des QSO ne déborde plus en bas de la fenêtre : il s'ouvre au-dessus du curseur quand la place manque en dessous.",
|
||||
"Confirmations QRZ.com : seul le marqueur de confirmation propre à QRZ compte désormais. Le téléchargement efface aussi les QSO qu'une version antérieure avait marqués confirmés alors que QRZ dit le contraire — lancez-le une fois pour corriger votre journal.",
|
||||
"Diplômes : un filtre par mode (Tous / CW / Phonie / Numérique) qui se cumule avec le filtre contacté/confirmé — « contacté en CW mais pas confirmé en CW » se lit en un clic."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.3",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Kenwood backend tested end to end against the built-in TS-2000 emulator.",
|
||||
"Kenwood: split detected from FR/FT when the radio omits it from its status frame.",
|
||||
"CAT protocol trace now covers the Kenwood backend, not only CI-V.",
|
||||
"Updating selected QSOs from the callsign databases shows a progress bar, without blocking the rest of OpsLog. Menu entry renamed \"Update from the callsign databases\".",
|
||||
"Grey line on the world map: day/night terminator and twilight band, button at the top right.",
|
||||
"The CAT and WinKeyer trace checkboxes now show whether the trace is really running.",
|
||||
"A radio reporting LSB or USB now selects SSB in the entry form.",
|
||||
"Kenwood CAT over the network: give a host:port for a serial bridge instead of a COM port.",
|
||||
"CAT backends renamed by how the radio is reached: OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, network), Xiegu (USB), Icom (CI-V USB), Icom (CI-V network), TCI.",
|
||||
"With automatic recording off, a red dot beside the callsign records the contact by hand.",
|
||||
"A recording can be stopped and transmitted to the station being worked, like a voice-keyer message.",
|
||||
"Award references can be sorted by reference or by description.",
|
||||
"Deleting a QSO can also withdraw it from QRZ.com and Club Log — Settings → External services, off by default.",
|
||||
"QSL and upload status columns are coloured: Y green, N red, R blue.",
|
||||
"Settings → General chooses between US / FR / Standard for dates. Display only.",
|
||||
"New award: DARC DOK Award (DLD)."
|
||||
],
|
||||
"fr": [
|
||||
"Backend Kenwood testé de bout en bout contre l’émulateur TS-2000 intégré.",
|
||||
"Kenwood : split détecté via FR/FT quand la radio ne le renseigne pas dans sa trame d’état.",
|
||||
"La trace du protocole CAT couvre aussi le backend Kenwood, plus seulement le CI-V.",
|
||||
"La mise à jour des QSO sélectionnés depuis les annuaires affiche une barre de progression, sans bloquer le reste d’OpsLog. Entrée de menu renommée « Mettre à jour depuis les annuaires ».",
|
||||
"Ligne grise sur la carte du monde : terminateur jour/nuit et bande de crépuscule, bouton en haut à droite.",
|
||||
"Les cases de trace CAT et WinKeyer reflètent désormais l’état réel de la trace.",
|
||||
"Une radio annonçant LSB ou USB sélectionne désormais SSB dans la saisie.",
|
||||
"CAT Kenwood par le réseau : indiquez un hôte:port de pont série au lieu d’un port COM.",
|
||||
"Backends CAT renommés selon la façon dont la radio est jointe : OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, réseau), Xiegu (USB), Icom (CI-V USB), Icom (CI-V réseau), TCI.",
|
||||
"Enregistrement automatique désactivé : un rond rouge à côté de l’indicatif enregistre le contact à la main.",
|
||||
"Un enregistrement peut être arrêté puis émis vers la station travaillée, comme un message du manipulateur vocal.",
|
||||
"Les références d’un diplôme peuvent être triées par référence ou par description.",
|
||||
"Supprimer un QSO peut aussi le retirer de QRZ.com et Club Log — Réglages → Services externes, désactivé par défaut.",
|
||||
"Colonnes de statut QSL et d’envoi colorées : Y vert, N rouge, R bleu.",
|
||||
"Réglages → Général : choix US / FR / Standard pour les dates. Affichage seulement.",
|
||||
"Nouveau diplôme : DARC DOK Award (DLD)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.2",
|
||||
"date": "2026-07-30",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// Which class of power a mode falls into.
|
||||
//
|
||||
// This decides how much power leaves the amplifier, so an error here is not a
|
||||
// display bug: a digital mode sorted as phone gets the SSB setting, which is
|
||||
// exactly the 1.5 kW into a 100% duty cycle this feature exists to prevent.
|
||||
func TestFlexPowerClass(t *testing.T) {
|
||||
for _, c := range []struct{ mode, want string }{
|
||||
{"SSB", "phone"}, {"USB", "phone"}, {"LSB", "phone"}, {"AM", "phone"}, {"FM", "phone"},
|
||||
{"CW", "cw"}, {"cw", "cw"}, {" CW ", "cw"},
|
||||
{"FT8", "digi"}, {"FT4", "digi"}, {"RTTY", "digi"}, {"PSK31", "digi"},
|
||||
{"JS8", "digi"}, {"Q65", "digi"}, {"MSK144", "digi"}, {"DATA", "digi"},
|
||||
{"DIGU", "digi"}, {"DIGL", "digi"}, {"WSPR", "digi"},
|
||||
// Unknown or empty: no class, and the caller changes nothing. Setting a
|
||||
// transmit power from a name we do not recognise is not a good guess.
|
||||
{"", ""}, {"SSTV", ""}, {"BANANA", ""},
|
||||
} {
|
||||
if got := flexPowerClass(c.mode); got != c.want {
|
||||
t.Errorf("flexPowerClass(%q) = %q, want %q", c.mode, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+459
-71
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock,
|
||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
SMTPConfigured, SendLogToDeveloper,
|
||||
WorkedBefore,
|
||||
SetCompactMode,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
|
||||
GetSecretStatus, UnlockSecrets,
|
||||
RefreshCtyDat, DownloadAllReferenceLists,
|
||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||
QSOAudioManualReady, QSOAudioManualStart, QSOAudioStop, QSOAudioResume, QSOAudioPlayOnAir,
|
||||
GetAwardDefs,
|
||||
GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
|
||||
ReportLiveActivity, LiveLastQSOAgeSec,
|
||||
@@ -76,6 +77,7 @@ import { IcomPanel } from '@/components/IcomPanel';
|
||||
import { YaesuPanel } from '@/components/YaesuPanel';
|
||||
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
||||
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
|
||||
import { AmpWidget } from '@/components/AmpWidget';
|
||||
import { ScpPanel, type ScpResult } from '@/components/ScpPanel';
|
||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||
@@ -99,6 +101,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { formatDateTimeUTC } from '@/lib/dateFormat';
|
||||
import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs';
|
||||
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
||||
|
||||
@@ -135,7 +138,15 @@ const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALV
|
||||
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
|
||||
// app.go — digital modes carry no useful audio, and CW has no DAX audio on Flex,
|
||||
// so neither is recorded (no REC badge / timer for them).
|
||||
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV']);
|
||||
// Modes a VOICE keyer may transmit on. Not a recording list — the DVK plays
|
||||
// speech, and sending it on CW or a data slot keys the rig with a human voice.
|
||||
const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV']);
|
||||
|
||||
// Modes worth recording. Phone, plus CW: the audio path stays open in CW, so
|
||||
// the other station's signal is captured. Your own sidetone is not in it —
|
||||
// SmartSDR does not route it through DAX — and that is accepted. Digital modes
|
||||
// stay out: their audio is a modem tone nobody will ever replay.
|
||||
const RECORDABLE_MODES = new Set([...PHONE_MODES, 'CW']);
|
||||
|
||||
const emptyDetails: DetailsState = {
|
||||
state: '', cnty: '', address: '',
|
||||
@@ -168,11 +179,7 @@ function parseAwardRefs(s: any): Record<string, string> {
|
||||
}
|
||||
|
||||
function fmtDateUTC(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return s;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
return formatDateTimeUTC(s);
|
||||
}
|
||||
function fmtFreq(hz?: number): string {
|
||||
if (!hz) return '';
|
||||
@@ -387,11 +394,40 @@ function WindowControls() {
|
||||
);
|
||||
}
|
||||
|
||||
// LockPad — the padlock toggle shown in a lockable field's label. Its icon
|
||||
// matches the state, so a glance says which fields are immune to CAT updates
|
||||
// and to the live clock.
|
||||
//
|
||||
// It lives HERE, at module level, and not inside App. Defined inside, React saw
|
||||
// a new component type on every render of App — and App re-renders about four
|
||||
// times a second while the CAT polls — so the button was unmounted and rebuilt
|
||||
// each time. It flickered under the pointer and swallowed clicks, because the
|
||||
// node being clicked no longer existed by the time the click landed.
|
||||
function LockPad({ on, title, onToggle }: { on: boolean; title: string; onToggle: () => void }) {
|
||||
const Icon = on ? Lock : Unlock;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={onToggle}
|
||||
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center size-3.5 rounded transition-colors',
|
||||
on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { t, lang } = useI18n();
|
||||
// === Lists from settings (fallback for first paint) ===
|
||||
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
|
||||
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
|
||||
const modesRef = useRef(modes);
|
||||
useEffect(() => { modesRef.current = modes; }, [modes]);
|
||||
const [modePresets, setModePresets] = useState<ModePreset[]>([]);
|
||||
|
||||
// === Entry ===
|
||||
@@ -447,27 +483,6 @@ export default function App() {
|
||||
return next;
|
||||
});
|
||||
};
|
||||
// Small padlock toggle rendered inside each lockable field's label. Match
|
||||
// the icon to the current state so the user can tell at a glance which
|
||||
// fields are immune to CAT updates / live clock.
|
||||
function LockBtn({ k, title }: { k: LockKey; title: string }) {
|
||||
const on = locks[k];
|
||||
const Icon = on ? Lock : Unlock;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => toggleLock(k)}
|
||||
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center size-3.5 rounded transition-colors',
|
||||
on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const [band, setBand] = useState('20m');
|
||||
const [mode, setMode] = useState('SSB');
|
||||
const [freqMhz, setFreqMhz] = useState('');
|
||||
@@ -477,6 +492,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('');
|
||||
@@ -556,6 +579,16 @@ export default function App() {
|
||||
// PGXL's OPERATE state comes from the radio when it reports the amp.
|
||||
const [ampSts, setAmpSts] = useState<any[]>([]);
|
||||
const [flexAmp, setFlexAmp] = useState<any>(null);
|
||||
// Amplifier widget: whether it is shown, and which amp — "all", or one amp id.
|
||||
// Several amps side by side make a wide widget, so an operator running two
|
||||
// picks the one he watches while transmitting. Declared here because the poll
|
||||
// below runs faster while the widget is open.
|
||||
const [showAmpWidget, setShowAmpWidget] = useState(() => localStorage.getItem('opslog.showAmpWidget') !== '0');
|
||||
const [ampWidgetSel, setAmpWidgetSel] = useState(() => localStorage.getItem('opslog.ampSel.widget') || 'all');
|
||||
// Poll fast only while the amplifier widget is open: its meters must track TX
|
||||
// the way the FlexRadio panel's do (400 ms), or the power reads seconds behind
|
||||
// the transmission and looks like the app is struggling. The status-bar chips
|
||||
// alone need nothing like that rate.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = () => Promise.all([
|
||||
@@ -563,9 +596,9 @@ export default function App() {
|
||||
GetFlexState().catch(() => null),
|
||||
]).then(([l, fx]: any[]) => { if (alive) { setAmpSts((l ?? []) as any[]); setFlexAmp(fx); } });
|
||||
tick();
|
||||
const id = window.setInterval(tick, 2000);
|
||||
const id = window.setInterval(tick, showAmpWidget ? 400 : 2000);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
}, [showAmpWidget]);
|
||||
// Multi-op "who's on air" widget: every operator's live status from the shared
|
||||
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
|
||||
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
|
||||
@@ -766,16 +799,59 @@ export default function App() {
|
||||
// Elapsed recording time (seconds) shown next to the red dot, ticking once a
|
||||
// second while a recording is in progress.
|
||||
const [recSeconds, setRecSeconds] = useState(0);
|
||||
// Mirrored in a ref so the clock can RESUME from where it stopped rather than
|
||||
// restart, without the timer effect depending on the value it sets.
|
||||
const recSecondsRef = useRef(0);
|
||||
useEffect(() => { recSecondsRef.current = recSeconds; }, [recSeconds]);
|
||||
// Bumped on every (re)start so the timer resets even when `recording` was
|
||||
// already true (jumping spot→spot keeps recording=true but starts a fresh take).
|
||||
const [recTick, setRecTick] = useState(0);
|
||||
// True when the sound devices are configured but automatic recording is OFF —
|
||||
// the only case where a record button makes sense. Asking the backend rather
|
||||
// than reading a preference here keeps the two from drifting apart.
|
||||
const [manualRecReady, setManualRecReady] = useState(false);
|
||||
// A stopped take is still a take: the audio is kept and will be saved with the
|
||||
// QSO. This only says the clock has stopped and the recording can be played.
|
||||
const [recStopped, setRecStopped] = useState(false);
|
||||
const stopRecording = () => {
|
||||
QSOAudioStop().then((ok) => { if (ok) setRecStopped(true); }).catch(() => {});
|
||||
};
|
||||
const resumeRecording = () => {
|
||||
QSOAudioResume().then((ok) => { if (ok) { setRecStopped(false); setRecTick((t) => t + 1); } }).catch(() => {});
|
||||
};
|
||||
// One button, two jobs: play, and stop what is playing.
|
||||
//
|
||||
// A second press used to RESTART the message from the beginning. What an
|
||||
// operator wants there is to cut it short — they have heard enough, or they
|
||||
// pressed it by mistake with the transmitter keyed — and then to be able to
|
||||
// send it again straight away. Stopping releases the PTT through the same
|
||||
// path the natural end uses.
|
||||
const playRecordingOnAir = () => {
|
||||
if (dvkStat.playing) { DVKStop(); return; }
|
||||
QSOAudioPlayOnAir().catch((e: any) => setError(String(e?.message ?? e)));
|
||||
};
|
||||
const refreshManualRecReady = () => { QSOAudioManualReady().then(setManualRecReady).catch(() => {}); };
|
||||
useEffect(() => { refreshManualRecReady(); }, []);
|
||||
const startManualRecording = () => {
|
||||
QSOAudioManualStart().then((active) => {
|
||||
setRecording(active);
|
||||
setRecTick((t) => t + 1);
|
||||
if (!active) setError(t('rec.manualFailed'));
|
||||
}).catch((e: any) => setError(String(e?.message ?? e)));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!recording) { setRecSeconds(0); return; }
|
||||
// A stopped take freezes the clock where it is: it must show the length of
|
||||
// the file, not the time since the QSO began.
|
||||
if (recStopped) return;
|
||||
const from = recSecondsRef.current;
|
||||
const start = Date.now();
|
||||
setRecSeconds(0);
|
||||
const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000);
|
||||
const id = window.setInterval(() => setRecSeconds(from + Math.floor((Date.now() - start) / 1000)), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [recording, recTick]);
|
||||
}, [recording, recTick, recStopped]);
|
||||
// recTick means "a fresh take" — that is the only case where the clock returns
|
||||
// to zero, as opposed to resuming after a stop.
|
||||
useEffect(() => { setRecSeconds(0); recSecondsRef.current = 0; setRecStopped(false); }, [recTick]);
|
||||
// The callsign the in-progress recording belongs to (uppercased; '' = none).
|
||||
// Lets us restart from zero when the operator edits the call to a different
|
||||
// station mid-recording, instead of continuing the old take.
|
||||
@@ -814,6 +890,36 @@ export default function App() {
|
||||
return ALWAYS_TABS.includes(saved) ? saved : 'recent';
|
||||
});
|
||||
useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]);
|
||||
|
||||
// Main tab: how much width the LEFT pane gets, in percent. Clamped to 15..85
|
||||
// so a pane can be made small but never dragged out of existence — recovering
|
||||
// from that means finding a divider that is no longer on screen.
|
||||
const [mainSplit, setMainSplit] = useState<number>(() => {
|
||||
const n = parseFloat(localStorage.getItem('opslog.mainSplit') || '');
|
||||
return Number.isFinite(n) && n >= 15 && n <= 85 ? n : 50;
|
||||
});
|
||||
useEffect(() => { writeUiPref('opslog.mainSplit', String(Math.round(mainSplit))); }, [mainSplit]);
|
||||
const mainSplitRef = useRef<HTMLDivElement | null>(null);
|
||||
const startMainSplitDrag = (e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
const host = mainSplitRef.current;
|
||||
if (!host) return;
|
||||
// Pointer capture on the divider, so dragging over a map keeps working —
|
||||
// Leaflet would otherwise swallow the moves the moment the cursor entered it.
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const r = host.getBoundingClientRect();
|
||||
if (r.width <= 0) return;
|
||||
const pct = ((ev.clientX - r.left) / r.width) * 100;
|
||||
setMainSplit(Math.min(85, Math.max(15, pct)));
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
};
|
||||
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
|
||||
const [qslTabOpen, setQslTabOpen] = useState(false);
|
||||
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
|
||||
@@ -1198,7 +1304,7 @@ export default function App() {
|
||||
useEffect(() => { dvkAutoCqSecsRef.current = dvkAutoCqSecs; localStorage.setItem('opslog.dvkAutoCqSecs', String(dvkAutoCqSecs)); }, [dvkAutoCqSecs]);
|
||||
// The DVK is a VOICE keyer — transmitting it on CW/FT8/RTTY would key the rig
|
||||
// with speech on a data slot. Only allow it on phone modes.
|
||||
const isPhoneMode = (m: string) => RECORDABLE_MODES.has((m || '').toUpperCase());
|
||||
const isPhoneMode = (m: string) => PHONE_MODES.has((m || '').toUpperCase());
|
||||
const modeRef = useRef(mode);
|
||||
useEffect(() => { modeRef.current = mode; }, [mode]);
|
||||
function stopDvkAutoCq() { dvkAutoCqSlotRef.current = -1; dvkAutoCqGenRef.current++; }
|
||||
@@ -1280,7 +1386,12 @@ export default function App() {
|
||||
// Status filter chips. Empty set = show every status (including
|
||||
// already-worked). Otherwise only matching spots pass.
|
||||
type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
|
||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(() => lsSet<SpotStatusKey>('opslog.clusterStatusFilter'));
|
||||
// What the cluster can be FILTERED on. A superset of the entity status: a new
|
||||
// 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' | '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).
|
||||
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
|
||||
@@ -1375,7 +1486,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);
|
||||
@@ -1518,6 +1629,8 @@ export default function App() {
|
||||
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
|
||||
const [importApplyCty, setImportApplyCty] = useState(true);
|
||||
const [importApplyStation, setImportApplyStation] = useState(false);
|
||||
// Progress of a bulk update (QRZ.com / cty.dat / ClubLog) over selected QSOs.
|
||||
const [bulkProgress, setBulkProgress] = useState<{ op: string; processed: number; total: number; call: string } | null>(null);
|
||||
// Field remapping, off unless the operator adds a row. Contest software puts
|
||||
// the exchange where its own module keeps it rather than where ADIF says: the
|
||||
// RSGB IOTA contest exports the island reference in STATE, which imports as a
|
||||
@@ -2007,11 +2120,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 {}
|
||||
@@ -2042,17 +2166,38 @@ export default function App() {
|
||||
// a 599 left from a CW QSO would stick when jumping to an SSB spot.
|
||||
function applyModeFromSpot(m: string) {
|
||||
if (!m) return;
|
||||
setMode(m);
|
||||
const logged = catModeToLoggedMode(m);
|
||||
setMode(logged);
|
||||
rstUserEditedRef.current = false;
|
||||
applyModePreset(m);
|
||||
applyModePreset(logged);
|
||||
}
|
||||
// catModeToLoggedMode maps what a radio reports onto a mode this station
|
||||
// actually logs.
|
||||
//
|
||||
// Radios report the SIDEBAND — a Kenwood or Yaesu on 40 m answers LSB, not
|
||||
// SSB. The mode list is SSB, so nothing matched and the selector sat empty
|
||||
// while the frequency tracked perfectly: "the mode is not recognised".
|
||||
//
|
||||
// It is also the ADIF-correct direction. LSB and USB are SUBMODEs there, not
|
||||
// modes; MODE=LSB is not a valid ADIF value and is what would be sent to LoTW
|
||||
// and eQSL. So this protects the upload as much as the selector.
|
||||
//
|
||||
// A station that has deliberately put LSB or USB in its own mode list keeps
|
||||
// them: the list is the operator's statement of what they log.
|
||||
function catModeToLoggedMode(m: string): string {
|
||||
const list = modesRef.current;
|
||||
if (!m || list.includes(m)) return m;
|
||||
if ((m === 'LSB' || m === 'USB') && list.includes('SSB')) return 'SSB';
|
||||
return m;
|
||||
}
|
||||
function applyModePreset(m: string) {
|
||||
if (rstUserEditedRef.current) return;
|
||||
// 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);
|
||||
}
|
||||
@@ -2211,8 +2356,9 @@ export default function App() {
|
||||
else if (s.mode) nextMode = s.mode;
|
||||
}
|
||||
if (nextMode) {
|
||||
setMode(nextMode);
|
||||
applyModePreset(nextMode); // flip 599↔59 on mode change (respects edits)
|
||||
const logged = catModeToLoggedMode(nextMode);
|
||||
setMode(logged);
|
||||
applyModePreset(logged); // flip 599↔59 on mode change (respects edits)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2238,6 +2384,25 @@ export default function App() {
|
||||
FlexApplyBandAntenna(b).catch(() => {});
|
||||
}, [band, catState.backend, locks.band]);
|
||||
|
||||
// Per-band, per-mode TX power. Applied on band AND mode changes — the point is
|
||||
// that moving to FT8 on a band where full power is fine for SSB does not put
|
||||
// full power into a 100% duty-cycle signal.
|
||||
//
|
||||
// Not skipped on a locked band, unlike the antennas: the lock means "do not
|
||||
// let the rig drag my log entry around", not "let the amplifier take whatever
|
||||
// the last mode left it at".
|
||||
const lastPwrRef = useRef('');
|
||||
useEffect(() => {
|
||||
if (catState.backend !== 'flex') return;
|
||||
const b = band.trim();
|
||||
const m = mode.trim();
|
||||
if (!b || !m) return;
|
||||
const key = b + '/' + m;
|
||||
if (key === lastPwrRef.current) return;
|
||||
lastPwrRef.current = key;
|
||||
FlexApplyBandPower(b, m).catch(() => {});
|
||||
}, [band, mode, catState.backend]);
|
||||
|
||||
// Cluster live wiring: hydrate per-server status + saved server list,
|
||||
// then subscribe to push events.
|
||||
async function reloadClusterMeta() {
|
||||
@@ -2305,6 +2470,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;
|
||||
@@ -2402,6 +2569,14 @@ export default function App() {
|
||||
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);
|
||||
// The closing event (processed === total) clears the overlay: the work is
|
||||
// done, and a bar left at 100% would have to be dismissed by hand.
|
||||
if (total > 0 && processed >= total) { setBulkProgress(null); return; }
|
||||
setBulkProgress({ op: String(p?.op ?? ''), processed, total, call: String(p?.call ?? '') });
|
||||
});
|
||||
const unsubProg = EventsOn('import:progress', (p: any) => {
|
||||
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
|
||||
});
|
||||
@@ -2428,7 +2603,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?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -2752,6 +2927,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;
|
||||
@@ -2860,7 +3037,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,
|
||||
@@ -2935,9 +3116,9 @@ export default function App() {
|
||||
}
|
||||
async function bulkUpdateFromQRZ(ids: number[]) {
|
||||
if (ids.length === 0) return;
|
||||
showToast(`Querying QRZ.com for ${ids.length} QSO${ids.length > 1 ? 's' : ''}…`);
|
||||
try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
finally { setBulkProgress(null); }
|
||||
}
|
||||
async function bulkUpdateFromClublog(ids: number[]) {
|
||||
if (ids.length === 0) return;
|
||||
@@ -3604,8 +3785,15 @@ export default function App() {
|
||||
// single-row strip or the full Log4OM-style columnar layout below. Keeping
|
||||
// them as shared consts avoids duplicating the (large) per-field JSX +
|
||||
// handlers across the two layouts.
|
||||
// The callsign field is WIDER than the fields around it, deliberately: it is
|
||||
// the one field always typed into, it carries the REC badges on its right, and
|
||||
// a long portable call (VK9/OH1ABC/MM) must stay readable as it is entered.
|
||||
// It was widened by taking room from the RST pair — until a signal report of
|
||||
// "59+30" turned up, which is five characters plus its padding and no longer
|
||||
// fitted. The RST fields are back to their original width; the callsign keeps
|
||||
// the rest of the row.
|
||||
const callsignBlock = (
|
||||
<div className="flex flex-col w-44" data-esm="call">
|
||||
<div className="flex flex-col w-56" data-esm="call">
|
||||
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
|
||||
<span className="text-primary font-semibold">{t('field.callsign')}</span>
|
||||
{lookupBusy && (
|
||||
@@ -3647,18 +3835,83 @@ export default function App() {
|
||||
DUPE
|
||||
</span>
|
||||
)}
|
||||
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
||||
{/* Not recording, but able to: offer a record button in the slot the
|
||||
counter will occupy. It disappears the moment recording starts, so the
|
||||
two never fight over the space — and it is only offered on modes that
|
||||
produce audio worth keeping. */}
|
||||
{!recording && manualRecReady && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={resetRecordingClock}
|
||||
title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-destructive whitespace-nowrap cursor-pointer rounded px-1 hover:bg-destructive-muted"
|
||||
onClick={startManualRecording}
|
||||
title={t('rec.manualStart')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center justify-center size-4 rounded-full cursor-pointer hover:bg-destructive-muted"
|
||||
>
|
||||
<span className="size-2 rounded-full bg-destructive animate-pulse" />
|
||||
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
|
||||
<span className="size-2.5 rounded-full border border-destructive bg-destructive/40 hover:bg-destructive" />
|
||||
</button>
|
||||
)}
|
||||
{/* A stopped take: play it to the station being worked, or carry on
|
||||
recording. Both sit where the counter is, which has shifted left to
|
||||
make room. */}
|
||||
{/* A stopped take: resume it, and — on PHONE only — play it to the station
|
||||
being worked. In CW the transmitter builds its tone from the keyer and
|
||||
ignores the DAX TX audio path entirely, so the button would key the rig
|
||||
and send silence. The row itself stays, or a CW operator who stopped
|
||||
would be left with no way to resume. */}
|
||||
{recording && recStopped && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1.5">
|
||||
{PHONE_MODES.has(mode.toUpperCase()) && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={playRecordingOnAir}
|
||||
title={dvkStat.playing ? t('rec.stopPlaying') : t('rec.playOnAir')}
|
||||
className={cn('inline-flex items-center justify-center size-4 rounded',
|
||||
dvkStat.playing ? 'text-destructive hover:bg-destructive-muted' : 'text-success hover:bg-success/15')}
|
||||
>
|
||||
{dvkStat.playing ? <span className="size-2 bg-current" /> : '▶'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={resumeRecording}
|
||||
title={t('rec.resume')}
|
||||
className="inline-flex items-center justify-center size-4 rounded text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<span className="size-2 rounded-full bg-destructive" />
|
||||
</button>
|
||||
<span className="text-[10px] font-semibold tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{/* Stop and the running counter share ONE flex row. They were two absolute
|
||||
boxes at guessed offsets and overlapped as soon as the counter passed a
|
||||
minute — a layout that only looked right for the first 60 seconds. */}
|
||||
{recording && !recStopped && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => { e.stopPropagation(); stopRecording(); }}
|
||||
title={t('rec.stop')}
|
||||
className="inline-flex items-center justify-center size-4 rounded text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<span className="size-2 bg-current" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={resetRecordingClock}
|
||||
title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange"
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-destructive whitespace-nowrap cursor-pointer rounded px-1 hover:bg-destructive-muted"
|
||||
>
|
||||
<span className="size-2 rounded-full bg-destructive animate-pulse" />
|
||||
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<Input
|
||||
ref={callsignRef}
|
||||
// The call field is marked out at REST, not only on focus: operators were
|
||||
@@ -3666,7 +3919,10 @@ export default function App() {
|
||||
// exactly the same. A primary-tinted border plus an inset left bar make
|
||||
// it the obvious one without adding a colour the theme doesn't own. The
|
||||
// contest-dupe state still overrides it — that one is a warning.
|
||||
// Room on the right for the REC badges while they are shown, so a long
|
||||
// callsign runs under them instead of being typed over.
|
||||
className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card',
|
||||
recording && RECORDABLE_MODES.has(mode.toUpperCase()) && 'pr-24',
|
||||
'border-primary/45 shadow-[inset_3px_0_0_0_var(--primary)]',
|
||||
contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground shadow-none')}
|
||||
value={callsign}
|
||||
@@ -3723,7 +3979,7 @@ export default function App() {
|
||||
) : null;
|
||||
const startBlock = (
|
||||
<div className="flex flex-col w-28">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockBtn k="start" title="start time" /></Label>
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockPad on={locks.start} title="start time" onToggle={() => toggleLock('start')} /></Label>
|
||||
<Input
|
||||
readOnly={!locks.start}
|
||||
tabIndex={locks.start ? 0 : -1}
|
||||
@@ -3742,7 +3998,7 @@ export default function App() {
|
||||
);
|
||||
const endBlock = (
|
||||
<div className="flex flex-col w-28">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')} <LockBtn k="end" title="end time" /></Label>
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')} <LockPad on={locks.end} title="end time" onToggle={() => toggleLock('end')} /></Label>
|
||||
<Input
|
||||
readOnly={!locks.end}
|
||||
tabIndex={locks.end ? 0 : -1}
|
||||
@@ -3932,7 +4188,7 @@ export default function App() {
|
||||
// used in the full layout to save vertical height.
|
||||
const bandRow = (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')} <LockBtn k="band" title="band" /></Label>
|
||||
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')} <LockPad on={locks.band} title="band" onToggle={() => toggleLock('band')} /></Label>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Select value={band} onValueChange={onBandUserChange}>
|
||||
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
||||
@@ -3943,7 +4199,7 @@ export default function App() {
|
||||
);
|
||||
const modeRow = (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')} <LockBtn k="mode" title="mode" /></Label>
|
||||
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')} <LockPad on={locks.mode} title="mode" onToggle={() => toggleLock('mode')} /></Label>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Select value={mode} onValueChange={onModeUserChange}>
|
||||
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
||||
@@ -3999,7 +4255,7 @@ export default function App() {
|
||||
};
|
||||
const freqBlock = (
|
||||
<div className="flex flex-col w-32">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockBtn k="freq" title="frequency" /></Label>
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockPad on={locks.freq} title="frequency" onToggle={() => toggleLock('freq')} /></Label>
|
||||
<Input
|
||||
tabIndex={-1}
|
||||
className="font-mono"
|
||||
@@ -4109,7 +4365,11 @@ export default function App() {
|
||||
// entity status is still new-band/new-slot (the grid flags it WKD CALL),
|
||||
// matching the "Hide worked" toggle. Additive: it still matches its own
|
||||
// entity status too, so it stays visible under NEW BAND / NEW SLOT.
|
||||
const matches = clusterStatusFilter.has(st) || (!!e?.worked_call && clusterStatusFilter.has('worked'));
|
||||
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_pfx && clusterStatusFilter.has('new-pfx'));
|
||||
if (!matches) return false;
|
||||
}
|
||||
if (clusterHideWorked) {
|
||||
@@ -4221,10 +4481,15 @@ export default function App() {
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{([
|
||||
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
|
||||
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' },
|
||||
{ k: 'new-mode' as SpotStatusKey, label: 'NEW MODE', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||
{ k: 'new' as SpotFilterKey, label: 'NEW', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
|
||||
{ k: 'new-band' as SpotFilterKey, label: 'NEW BAND', cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' },
|
||||
{ k: 'new-mode' as SpotFilterKey, label: 'NEW MODE', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||
{ k: 'new-slot' as SpotFilterKey, label: 'NEW SLOT', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||
// 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-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);
|
||||
@@ -4625,6 +4890,60 @@ export default function App() {
|
||||
{showTuner && tgStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
|
||||
</button>
|
||||
)}
|
||||
{/* Amplifier widget. With one amp the icon is a plain toggle; with
|
||||
several it also carries a dropdown to choose which one the widget
|
||||
shows (or All), since two amps stacked make a tall widget. */}
|
||||
{ampSts.length > 0 && (
|
||||
<div className="relative inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { const v = !showAmpWidget; setShowAmpWidget(v); writeUiPref('opslog.showAmpWidget', v ? '1' : '0'); }}
|
||||
title={showAmpWidget ? t('ampw.hideHint') : t('ampw.showHint')}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||
ampSts.length > 1 && 'rounded-r-none border-r-0',
|
||||
showAmpWidget ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||
: 'border-border text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<Flame className="size-4" />
|
||||
{showAmpWidget && ampSts.some((a: any) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flexAmp?.amp_available) && (
|
||||
<span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />
|
||||
)}
|
||||
</button>
|
||||
{ampSts.length > 1 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" title={t('ampw.pick')}
|
||||
className={cn('inline-flex items-center justify-center h-7 w-4 rounded-md rounded-l-none border transition-colors',
|
||||
showAmpWidget ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||
: 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-44">
|
||||
{[{ id: 'all', name: t('ampw.all') }, ...ampSts.map((a: any) => ({ id: a.id, name: a.name || a.id }))].map((o) => (
|
||||
<DropdownMenuItem key={o.id}
|
||||
onSelect={() => {
|
||||
setAmpWidgetSel(o.id);
|
||||
writeUiPref('opslog.ampSel.widget', o.id);
|
||||
// Picking an amp means "show me this one" — opening the
|
||||
// widget too saves a second click on a hidden one.
|
||||
if (!showAmpWidget) { setShowAmpWidget(true); writeUiPref('opslog.showAmpWidget', '1'); }
|
||||
}}>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{ampWidgetSel === o.id
|
||||
? <Check className="size-3.5 shrink-0 text-primary" />
|
||||
: <span className="size-3.5 shrink-0" />}
|
||||
<span className="truncate">{o.name}</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{scpEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -5119,6 +5438,11 @@ export default function App() {
|
||||
prefix={prefix}
|
||||
operatorGrid={station.my_grid}
|
||||
remoteGrid={grid}
|
||||
qth={qth}
|
||||
name={name}
|
||||
country={country}
|
||||
comment={comment}
|
||||
note={note}
|
||||
details={details}
|
||||
onChange={updateDetails}
|
||||
wb={wb}
|
||||
@@ -5136,7 +5460,7 @@ export default function App() {
|
||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||
otherwise it shows the QRZ profile photo. */}
|
||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showScp && scpEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
|
||||
// the DVK with Auto CQ) can't grow the row — the row height stays set by
|
||||
// the entry strip and each widget fills that height, scrolling inside.
|
||||
@@ -5223,6 +5547,19 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showAmpWidget && ampSts.length > 0 && (
|
||||
// One column per amplifier shown, so two amps stand side by side
|
||||
// rather than making the widget twice as tall as the dock row.
|
||||
<div className="shrink-0 min-h-0"
|
||||
style={{ width: `${Math.min(ampWidgetSel === 'all' ? ampSts.length : 1, 3) * 250 + 20}px` }}>
|
||||
<AmpWidget
|
||||
amps={ampSts}
|
||||
flex={flexAmp}
|
||||
sel={ampWidgetSel}
|
||||
onClose={() => { setShowAmpWidget(false); writeUiPref('opslog.showAmpWidget', '0'); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showTuner && tgEnabled && (
|
||||
<div className="w-[230px] shrink-0 min-h-0">
|
||||
<TunerGeniusPanel
|
||||
@@ -5819,8 +6156,23 @@ export default function App() {
|
||||
{/* Two configurable panes (per-profile, Settings → Main view).
|
||||
Each side shows one of: great-circle map, locator map, cluster
|
||||
or worked-before. */}
|
||||
<div className="grid grid-cols-2 grid-rows-1 gap-2 h-full min-h-0 p-2">
|
||||
{/* The divider is draggable: a map and a cluster list want very
|
||||
different widths, and which one deserves the room changes with
|
||||
what the operator is doing. The share is persisted (portable),
|
||||
and a double-click puts it back to even. */}
|
||||
<div ref={mainSplitRef} className="grid grid-rows-1 gap-2 h-full min-h-0 p-2"
|
||||
style={{ gridTemplateColumns: `${mainSplit}fr 6px ${100 - mainSplit}fr` }}>
|
||||
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneLeft)}</div>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title={t('main.splitTip')}
|
||||
onPointerDown={startMainSplitDrag}
|
||||
onDoubleClick={() => setMainSplit(50)}
|
||||
className="group relative cursor-col-resize flex items-center justify-center -mx-1 px-1"
|
||||
>
|
||||
<span className="h-10 w-1 rounded-full bg-border group-hover:bg-primary transition-colors" />
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneRight)}</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -6158,7 +6510,14 @@ export default function App() {
|
||||
<SettingsModal
|
||||
initialSection={settingsSection}
|
||||
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
|
||||
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }}
|
||||
onSaved={() => {
|
||||
loadStation(); loadLists(); loadCATCfg(); reloadWk(); refreshManualRecReady();
|
||||
// Drop the cached spot statuses. They are computed once per
|
||||
// call+band+mode and never expire, so a rule change in Settings —
|
||||
// grouping the digital modes into one slot, above all — left every
|
||||
// spot already on screen showing the answer to the OLD question.
|
||||
setSpotStatus({});
|
||||
}}
|
||||
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
|
||||
flexAvailable={catState.backend === 'flex'}
|
||||
icomAvailable={catState.backend === 'icom'}
|
||||
@@ -6384,6 +6743,35 @@ export default function App() {
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Deliberately NOT a modal. A contest log is thousands of QSOs and one
|
||||
network round trip each, so this runs for ten minutes \u2014 a dialog would
|
||||
hold the whole application hostage for the duration, and the operator
|
||||
has a radio to work. It sits in the corner, above everything, and
|
||||
stays out of the way. */}
|
||||
{bulkProgress && (
|
||||
<div className="fixed bottom-3 right-3 z-50 w-64 rounded-lg border border-border bg-card/95 backdrop-blur shadow-lg px-3 py-2 space-y-1.5">
|
||||
<div className="text-xs font-medium">{t('bulk.progressTitle')}</div>
|
||||
{(() => {
|
||||
const { processed, total, call } = bulkProgress;
|
||||
const pct = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0;
|
||||
return (
|
||||
<>
|
||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full bg-primary rounded-full transition-[width] duration-150', total === 0 && 'w-1/3 animate-pulse')}
|
||||
style={total > 0 ? { width: `${pct}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span className="font-mono">{call || '\u00a0'}</span>
|
||||
<span className="tabular-nums">{total > 0 ? `${processed} / ${total}` : ''}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importing && (
|
||||
<Dialog open>
|
||||
<DialogContent className="max-w-sm px-6" hideClose>
|
||||
|
||||
@@ -51,11 +51,14 @@ type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgx
|
||||
|
||||
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
|
||||
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
|
||||
// One second: the same window as the docked widget, so the two never show
|
||||
// different figures for the same amplifier, and neither trails the end of a
|
||||
// transmission long enough to look like lag.
|
||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||
const peakHold = (key: string, val: number) => {
|
||||
const now = Date.now();
|
||||
const p = peak.current[key];
|
||||
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
|
||||
@@ -73,9 +76,12 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
|
||||
is off (no status = not "connected"), and serial only. */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!spe.connected}
|
||||
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
|
||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||
@@ -177,9 +183,6 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
||||
</span>
|
||||
{(pg.host || pg.connected) && (
|
||||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||||
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
@@ -208,6 +211,11 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||
if (amps.length === 0) return null;
|
||||
// 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 survives in
|
||||
// the last-known status after the amp disconnects — so it claimed
|
||||
// 1350 W from an old transmission while the radio was putting out 10.
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||
{amps.map((m) => {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useRef } from 'react';
|
||||
import { Flame, X, Power } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
|
||||
|
||||
// AmpWidget — the docked amplifier widget, the compact counterpart of AmpCard
|
||||
// (which is the full-size card shown in the FlexRadio panel and Station
|
||||
// Control). It covers all three families, since an operator running an SPE and
|
||||
// a PowerGenius wants one widget, not two:
|
||||
// • SPE Expert — OPERATE, power ON/OFF, L/M/H, live power / SWR / temp
|
||||
// • ACOM — OPERATE, power ON/OFF, live power / SWR / temp
|
||||
// • PowerGenius — OPERATE and meters ride on the FlexRadio, fan mode on GSCP
|
||||
// With several amplifiers configured the caller passes a selection ("all" or an
|
||||
// amp id) chosen from the toolbar icon's dropdown.
|
||||
|
||||
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
||||
|
||||
// Full-scale watts for the output bar, from the model string.
|
||||
function maxW(model?: string, fallback = 1300): number {
|
||||
const m = (model || '').toUpperCase();
|
||||
if (m.includes('20K') || m.includes('2K')) return 2000;
|
||||
if (m.includes('15K')) return 1500;
|
||||
if (m.includes('13K')) return 1300;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function swrColour(swr?: number): string {
|
||||
if (!swr || swr <= 0) return 'text-muted-foreground';
|
||||
if (swr <= 1.5) return 'text-success';
|
||||
if (swr <= 2.0) return 'text-warning';
|
||||
return 'text-danger';
|
||||
}
|
||||
|
||||
// PowerMeter — the output-power LED bar that closes each column, straight under
|
||||
// the controls (no filler above it: pushed to the bottom it left a band of dead
|
||||
// space in the middle of the column). Uses the app-wide MeterBar, compact size,
|
||||
// so it reads exactly like the meters on the rig panels.
|
||||
function PowerMeter({ label, watts, maxWatts }: { label: string; watts: number; maxWatts: number }) {
|
||||
return (
|
||||
<MeterBar compact label={label} value={watts} unit="W" lo={0} hi={maxWatts}
|
||||
display={`${Math.round(watts)} W`}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
);
|
||||
}
|
||||
|
||||
// usePeakHold keeps the highest reading seen for a short window, so a reading
|
||||
// sampled between two syllables doesn't collapse the meter. `live` is the key
|
||||
// part: pass false the moment the carrier drops and the held value is thrown
|
||||
// away at once — holding it there made releasing the PTT look like a five
|
||||
// second lag, which read as the whole app being slow.
|
||||
function usePeakHold(windowMs = 1000) {
|
||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||
return (key: string, val: number, live = true) => {
|
||||
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; }
|
||||
return p.v;
|
||||
};
|
||||
}
|
||||
|
||||
// Stat — one small labelled figure (power, SWR, temperature).
|
||||
function Stat({ label, value, className }: { label: string; value: string; className?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card/70 px-1.5 py-1 text-center">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground leading-tight">{label}</div>
|
||||
<div className={cn('text-sm font-bold font-mono leading-tight', className ?? 'text-foreground/80')}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// OperateButton — the STANDBY/OPERATE toggle, identical in behaviour across the
|
||||
// three families (only where the state comes from differs).
|
||||
function OperateButton({ operate, disabled, onClick, t }: {
|
||||
operate: boolean; disabled: boolean; onClick: () => void; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" disabled={disabled} onClick={onClick}
|
||||
className={cn('w-full inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-[0.98] disabled:opacity-40',
|
||||
operate
|
||||
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_9px_rgba(245,158,11,0.45)]'
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground')}>
|
||||
<Power className="size-3.5" />
|
||||
{operate ? t('ampw.operate') : t('ampw.standby')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// AmpBlock — one amplifier inside the widget.
|
||||
function AmpBlock({ amp, flex, showName, t }: {
|
||||
amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
const spe = amp.spe, acom = amp.acom;
|
||||
const hold = usePeakHold();
|
||||
|
||||
if (spe || acom) {
|
||||
const s = spe || acom;
|
||||
// The amp reports zero watts on receive, so its TX flag clears the meter as
|
||||
// soon as the operator lets go — and the radio's own flag, when we have one,
|
||||
// gets there first (the amp is polled on its own slower cycle).
|
||||
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false;
|
||||
const w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing);
|
||||
const swr = Number(spe ? s.swr_ant : s.swr) || 0;
|
||||
const hi = spe ? maxW(s.model) : (Number(s.max_w) || 800);
|
||||
// Power ON drives the remote-on control lines, so it stays available while
|
||||
// the amplifier is off and reporting nothing — but only over a serial link.
|
||||
const canPowerOn = spe ? (s.connected || s.transport === 'serial') : (s.port_open && s.transport === 'serial');
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-1.5">
|
||||
{showName && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', s.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : 'ACOM')}</span>
|
||||
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
|
||||
</div>
|
||||
)}
|
||||
<OperateButton operate={!!s.operate} disabled={!s.connected} t={t}
|
||||
onClick={() => AmpOperate(amp.id, !s.operate).catch(() => {})} />
|
||||
<div className="flex gap-1.5">
|
||||
<button type="button" disabled={!canPowerOn}
|
||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||
className="flex-1 rounded-lg border border-success/60 bg-card py-1 text-[11px] font-bold text-success hover:bg-success/15 disabled:opacity-30">
|
||||
{t('ampw.on')}
|
||||
</button>
|
||||
<button type="button" disabled={!s.connected}
|
||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||
className="flex-1 rounded-lg border border-danger/60 bg-card py-1 text-[11px] font-bold text-danger hover:bg-danger/15 disabled:opacity-30">
|
||||
{t('ampw.off')}
|
||||
</button>
|
||||
</div>
|
||||
{/* Output level — SPE only; an ACOM has no L/M/H over CAT. */}
|
||||
{spe && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{(['L', 'M', 'H'] as const).map((lvl) => {
|
||||
const active = (s.power_level || '').trim().toUpperCase() === lvl;
|
||||
return (
|
||||
<button key={lvl} type="button" disabled={!s.connected}
|
||||
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
|
||||
className={cn('rounded-md border py-0.5 text-[10px] font-bold transition-colors disabled:opacity-30',
|
||||
active ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{t(`ampw.lvl${lvl}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{s.connected ? (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<Stat label={t('ampw.pwr')} value={`${Math.round(w)}`} />
|
||||
<Stat label={t('ampw.swr')} value={swr > 0 ? swr.toFixed(1) : '—'} className={swrColour(swr)} />
|
||||
<Stat label={t('ampw.temp')} value={`${Number(s.temp_c) || 0}°`} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||
)}
|
||||
{(s.warnings || s.alarms || s.err_text) && (
|
||||
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
||||
{s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
|
||||
</div>
|
||||
)}
|
||||
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PowerGenius XL: OPERATE and the meters come from the radio when the Flex
|
||||
// reports the amp; the fan mode rides on our own GSCP link.
|
||||
const pg = amp.pgxl || {};
|
||||
const viaFlex = !!flex?.amp_available;
|
||||
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
||||
const connected = !!pg.connected || viaFlex;
|
||||
const fault = flex?.amp_fault;
|
||||
const meters = ((flex?.meters as any[]) || []).filter((m) => (m.src || '').toUpperCase().includes('AMP'));
|
||||
const meter = (re: RegExp) => meters.find((m) => re.test((m.name || '').trim()));
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
const fwd = meter(/^fwd|pwr/i);
|
||||
const flexW = fwd ? (/dbm/i.test(fwd.unit || '') ? dbmToW(fwd.value) : fwd.value) : 0;
|
||||
// Whether there is power is the RADIO's transmit flag: it flips the instant
|
||||
// the PTT does, where the amplifier's own status poll answers a second and a
|
||||
// half late and holds TRANSMIT past the carrier.
|
||||
//
|
||||
// How MUCH is the live reading — the radio's meter stream, or the amp's own
|
||||
// forward figure when there is no Flex. NOT the amp's "peakfwd": that is a
|
||||
// latched maximum which is never reset AND survives in the last-known status
|
||||
// after the amp disconnects, so it reported 1350 W from an old transmission
|
||||
// while the radio was putting out 10 W into a disconnected amplifier.
|
||||
const txing = typeof flex?.transmitting === 'boolean'
|
||||
? flex.transmitting
|
||||
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
|
||||
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.
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-1.5">
|
||||
{showName && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || 'PGXL'}</span>
|
||||
</div>
|
||||
)}
|
||||
<OperateButton operate={operate} disabled={!connected} t={t}
|
||||
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})} />
|
||||
{connected ? (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<Stat label={t('ampw.pwr')} value={`${Math.round(fwdW)}`} />
|
||||
<Stat label={t('ampw.id')} value={idA != null ? idA.toFixed(1) : '—'} />
|
||||
<Stat label={t('ampw.temp')} value={tempC != null ? `${Math.round(tempC)}°` : '—'} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||
)}
|
||||
{(pg.host || pg.connected) && (
|
||||
<select
|
||||
disabled={!pg.connected}
|
||||
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
|
||||
title={t('ampw.fan')}
|
||||
className="w-full h-7 rounded-md border border-border bg-card px-1.5 text-[10px] font-semibold outline-none focus:border-warning disabled:opacity-40">
|
||||
<option value="STANDARD">{t('flxp.fanStandard')}</option>
|
||||
<option value="CONTEST">{t('flxp.fanContest')}</option>
|
||||
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
|
||||
</select>
|
||||
)}
|
||||
{fault && fault !== 'NONE' && (
|
||||
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
||||
{t('flxp.fault')}: {fault}
|
||||
</div>
|
||||
)}
|
||||
<PowerMeter label={t('flxp.outputPower')} watts={connected ? fwdW : 0} maxWatts={2000} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmpWidget({ amps, flex, sel, onClose }: {
|
||||
amps: Amp[];
|
||||
flex: any;
|
||||
sel: string; // "all" or an amp id
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
// An id that no longer matches (the amp was removed in Settings) falls back to
|
||||
// showing everything rather than an empty widget.
|
||||
const shown = sel === 'all' ? amps : (amps.filter((a) => a.id === sel).length ? amps.filter((a) => a.id === sel) : amps);
|
||||
const anyOnline = shown.some((a) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flex?.amp_available);
|
||||
const title = shown.length === 1 ? (shown[0].name || t('ampw.title')) : t('ampw.title');
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||
<Flame className={cn('size-4', anyOnline ? 'text-warning drop-shadow-[0_0_3px_rgba(245,158,11,0.55)]' : 'text-muted-foreground')} />
|
||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80 truncate">{title}</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('ampw.close')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Several amplifiers sit SIDE BY SIDE, one column each: stacked, two amps
|
||||
made a widget taller than the dock row and both ended up half hidden. */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2.5">
|
||||
{shown.length === 0 ? (
|
||||
<div className="text-[11px] text-center text-muted-foreground italic py-2">{t('ampw.none')}</div>
|
||||
) : (
|
||||
<div className="grid gap-2.5 h-full" style={{ gridTemplateColumns: `repeat(${shown.length}, minmax(0, 1fr))` }}>
|
||||
{shown.map((a, i) => (
|
||||
<div key={a.id} className={cn(i > 0 && 'pl-2.5 border-l border-border/50')}>
|
||||
<AmpBlock amp={a} flex={flex} showName={shown.length > 1} t={t} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export type AwardDef = {
|
||||
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
||||
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
||||
ref_display?: string; // grid column shows: ref | name | both
|
||||
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
||||
type?: string; field: string; match_by?: string; exact_match?: boolean; one_ref_per_qso?: boolean; pattern: string;
|
||||
leading_str?: string; trailing_str?: string; dynamic?: boolean;
|
||||
or_rules?: AwardOrRule[];
|
||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||
@@ -227,7 +227,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
};
|
||||
type Explanation = {
|
||||
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
|
||||
ref_count: number; steps: Step[]; manual?: string[]; result: string[];
|
||||
ref_count: number; steps: Step[]; manual?: string[]; ambiguous?: string[]; result: string[];
|
||||
};
|
||||
type TestRow = { qso: any; explanation: Explanation };
|
||||
const [testCall, setTestCall] = useState('');
|
||||
@@ -554,6 +554,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</div>
|
||||
</Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> {t('awed.exactMatch')}</label>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]" title={t('awed.oneRefHint')}><Checkbox checked={!!cur.one_ref_per_qso} onCheckedChange={(c) => patch({ one_ref_per_qso: !!c })} /> {t('awed.oneRef')}</label>
|
||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||
@@ -708,6 +709,13 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(ex.ambiguous ?? []).length > 0 && (
|
||||
<div className="px-3 py-2 text-xs">
|
||||
<span className="font-semibold uppercase text-[10px] tracking-wide text-warning">{t('awed.testAmbiguous')}</span>{' '}
|
||||
<span className="font-mono text-warning">{(ex.ambiguous ?? []).join(', ')}</span>{' '}
|
||||
<span className="text-muted-foreground">{t('awed.testAmbiguousHint')}</span>
|
||||
</div>
|
||||
)}
|
||||
{(ex.manual ?? []).length > 0 && (
|
||||
<div className="px-3 py-2 text-xs">
|
||||
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
|
||||
|
||||
@@ -8,12 +8,15 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AwardEditor } from '@/components/AwardEditor';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
type BandCount = { band: string; worked: number; confirmed: number };
|
||||
type AwardRef = {
|
||||
ref: string; name?: string; group?: string; subgrp?: string;
|
||||
worked: boolean; confirmed: boolean; validated: boolean;
|
||||
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
|
||||
// Mode CLASSES — "CW" | "PHONE" | "DIGI" — not ADIF modes.
|
||||
modes?: string[]; confirmed_modes?: string[];
|
||||
};
|
||||
type AwardResult = {
|
||||
code: string; name: string; dimension: string;
|
||||
@@ -66,6 +69,9 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
||||
// Computed results are cached per award code — each award is scanned only the
|
||||
// first time it's selected (or when explicitly rescanned).
|
||||
// Keyed by "CODE|MODECLASS": the same award computed for CW and for all modes
|
||||
// are different results, and caching them under one key showed the previous
|
||||
// mode’s numbers after switching.
|
||||
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
@@ -74,6 +80,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
||||
// Mode filter, stacked ON TOP of the status one. "Worked on CW but not
|
||||
// confirmed" is two questions at once, and answering only one of them is what
|
||||
// sends an operator to a spreadsheet.
|
||||
const [modeFilter, setModeFilter] = useState<'all' | 'CW' | 'PHONE' | 'DIGI'>('all');
|
||||
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
|
||||
const [showMissing, setShowMissing] = useState(false);
|
||||
const [stats, setStats] = useState<AwardStats | null>(null);
|
||||
@@ -112,13 +122,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
// Compute one award (cached). force=true bypasses the cache (Rescan).
|
||||
async function compute(code: string, force = false) {
|
||||
if (!code) return;
|
||||
if (!force && byCode[code]) { setSelected(code); return; }
|
||||
const key = `${code}|${modeFilter}`;
|
||||
if (!force && byCode[key]) { setSelected(code); return; }
|
||||
setSelected(code);
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const r = (await GetAward(code)) as any as AwardResult;
|
||||
setByCode((m) => ({ ...m, [code]: r }));
|
||||
const r = (await GetAward(code, modeFilter === 'all' ? '' : modeFilter)) as any as AwardResult;
|
||||
setByCode((m) => ({ ...m, [key]: r }));
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
@@ -142,7 +153,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
}
|
||||
useEffect(() => { loadList(); }, []);
|
||||
|
||||
const current = byCode[selected];
|
||||
const current = byCode[`${selected}|${modeFilter}`];
|
||||
// Recompute when the mode class changes: the bands, counts and confirmations
|
||||
// all describe the selected mode, so they cannot be filtered client-side.
|
||||
useEffect(() => { if (selected) compute(selected); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [modeFilter]);
|
||||
|
||||
// Bands relevant to the selected award, used by BOTH the grid and the stats
|
||||
// matrix so neither is padded with bands the award doesn't use. Rule: the
|
||||
@@ -191,17 +205,62 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
return idx.length ? idx : all;
|
||||
}, [stats, awardBands]);
|
||||
|
||||
// Sort order for the reference table. Reference is the default because that
|
||||
// is how award lists are published and how an operator reads a paper list;
|
||||
// description answers the other question — "have I worked anything in
|
||||
// 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 [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
|
||||
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
|
||||
const toggleRefSort = (k: 'ref' | 'name') => {
|
||||
if (k === refSort) {
|
||||
const d = refSortDir === 'asc' ? 'desc' : 'asc';
|
||||
setRefSortDir(d);
|
||||
writeUiPref('opslog.awardRefSortDir', d);
|
||||
} else {
|
||||
setRefSort(k);
|
||||
setRefSortDir('asc');
|
||||
writeUiPref('opslog.awardRefSort', k);
|
||||
writeUiPref('opslog.awardRefSortDir', 'asc');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredRefs = useMemo(() => {
|
||||
if (!current) return [];
|
||||
const q = refSearch.trim().toUpperCase();
|
||||
return (current.refs ?? []).filter((r) => {
|
||||
const rows = (current.refs ?? []).filter((r) => {
|
||||
if (refFilter === 'worked' && !r.worked) return false;
|
||||
if (refFilter === 'notworked' && r.worked) return false;
|
||||
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||
if (modeFilter !== 'all' && refFilter !== 'notworked') {
|
||||
// A reference never worked has no mode, so "not worked" plus a mode is
|
||||
// a contradiction: the mode filter stands aside rather than emptying
|
||||
// the list.
|
||||
if (!(r.modes ?? []).includes(modeFilter)) return false;
|
||||
// Not-confirmed must mean not confirmed ON THIS MODE. An entity worked
|
||||
// on CW and confirmed on SSB is still a CW entity to chase, and the
|
||||
// whole point of the filter is to find those.
|
||||
if (refFilter === 'worked_notconf' && (r.confirmed_modes ?? []).includes(modeFilter)) return false;
|
||||
}
|
||||
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
|
||||
return true;
|
||||
});
|
||||
}, [current, refSearch, refFilter]);
|
||||
const dir = refSortDir === 'asc' ? 1 : -1;
|
||||
return rows.sort((a, b) => {
|
||||
if (refSort === 'name') {
|
||||
// localeCompare, not a byte comparison: these lists are Spanish, French
|
||||
// and Italian place names, where "Ávila" belongs beside "Avilés" rather
|
||||
// than after "Zaragoza".
|
||||
const c = (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' });
|
||||
if (c !== 0) return c * dir;
|
||||
return a.ref.localeCompare(b.ref) * dir; // stable tie-break
|
||||
}
|
||||
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||
});
|
||||
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
@@ -240,7 +299,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
<div className="flex-1 overflow-auto">
|
||||
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
|
||||
{awardList.map((a) => {
|
||||
const r = byCode[a.code];
|
||||
const r = byCode[`${a.code}|${modeFilter}`];
|
||||
return (
|
||||
<button
|
||||
key={a.code}
|
||||
@@ -325,6 +384,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||
{([['all', t('awp.filterAll')], ['CW', 'CW'], ['PHONE', t('awp.modePhone')], ['DIGI', t('awp.modeDigital')]] as const).map(([k, label]) => (
|
||||
<button key={k} onClick={() => setModeFilter(k)}
|
||||
className={cn('px-2 py-1', modeFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||
<button
|
||||
onClick={() => setShowMissing(true)}
|
||||
@@ -386,8 +453,18 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-card">
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">{t('awp.ref')}</th>
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</th>
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">
|
||||
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.ref')}
|
||||
{refSort === 'ref' && (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')}
|
||||
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
{gridBands.map((b) => (
|
||||
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
|
||||
))}
|
||||
@@ -424,8 +501,18 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.ref')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium">{t('awp.name')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-24">
|
||||
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.ref')}
|
||||
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-1.5 pr-2 font-medium">
|
||||
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.name')}
|
||||
{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-24">{t('awp.status')}</th>
|
||||
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
||||
@@ -459,7 +546,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
</div>
|
||||
|
||||
{cell && current && (
|
||||
<CellQSOModal code={current.code} cell={cell} onClose={() => setCell(null)} />
|
||||
<CellQSOModal code={current.code} cell={cell} modeClass={modeFilter === 'all' ? '' : modeFilter} onClose={() => setCell(null)} />
|
||||
)}
|
||||
{showMissing && current && (
|
||||
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
|
||||
@@ -644,17 +731,17 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
}
|
||||
|
||||
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
|
||||
function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; onClose: () => void }) {
|
||||
function CellQSOModal({ code, cell, modeClass, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; modeClass: string; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [qsos, setQsos] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
AwardCellQSOs(code, cell.ref, cell.band)
|
||||
AwardCellQSOs(code, cell.ref, cell.band, modeClass)
|
||||
.then((r) => setQsos((r ?? []) as any))
|
||||
.catch(() => setQsos([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [code, cell.ref, cell.band]);
|
||||
}, [code, cell.ref, cell.band, modeClass]);
|
||||
|
||||
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
||||
import { ComputeQSOAwardRefs, IsNewUSCounty } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -53,6 +53,14 @@ interface Props {
|
||||
prefix: string;
|
||||
operatorGrid: string; // station.my_grid — origin for bearing/distance
|
||||
remoteGrid: string; // entry-strip Grid value — destination
|
||||
// Entry-strip text fields. Live award detection matches on these too — a DARC
|
||||
// DOK keys off QTH, not the address — and sending only the address meant such
|
||||
// an award stayed silent until the QSO was logged.
|
||||
qth?: string;
|
||||
name?: string;
|
||||
country?: string;
|
||||
comment?: string;
|
||||
note?: string;
|
||||
details: DetailsState;
|
||||
onChange: (patch: Partial<DetailsState>) => void;
|
||||
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
|
||||
@@ -113,28 +121,57 @@ function numOrUndef(v: string): number | undefined {
|
||||
return isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
// Column spans written out rather than built as `col-span-${n}`: Tailwind scans
|
||||
// for literal class names, so an interpolated one is never emitted.
|
||||
const SPAN_CLASS: Record<number, string> = {
|
||||
1: '', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4', 5: 'col-span-5',
|
||||
6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8', 9: 'col-span-9',
|
||||
10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12',
|
||||
};
|
||||
|
||||
// Compact field helper to keep the JSX dense.
|
||||
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 4 | 6; children: React.ReactNode }) {
|
||||
function Field({ label, span = 1, className, children }: { label: string; span?: number; className?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 4 && 'col-span-4', span === 6 && 'col-span-6')}>
|
||||
<div className={cn('flex flex-col min-w-0', SPAN_CLASS[span], className)}>
|
||||
<Label className="mb-1">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, 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, tab, onTab, keyerActive }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||
|
||||
// Live award detection: run the SAME engine used at log time over the current
|
||||
// contact (callsign + looked-up address/state/zones) so award references the
|
||||
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
|
||||
// surfaced and auto-added the moment you enter a call or click a spot, instead
|
||||
// of only appearing after logging. Pickable matches are merged into award_refs
|
||||
// contact — callsign, the looked-up address/state/zones AND the entry-strip
|
||||
// text (QTH, name, country, comment, note, grid). Sending only the address was
|
||||
// a silent hole: a DARC DOK matches on QTH, so "Aachen" produced nothing here
|
||||
// while the award editor's Test tab, which passes the whole QSO, matched it.
|
||||
// References the QSO will earn — WAPC matching "Beijing" inside the address,
|
||||
// a DOK matching the town — are surfaced and auto-added the moment you enter a
|
||||
// call or click a spot, instead of only appearing after logging. Pickable
|
||||
// matches are merged into award_refs
|
||||
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
|
||||
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean }>>([]);
|
||||
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean; ambiguous?: boolean }>>([]);
|
||||
|
||||
// NEW badge on the county: the same question the cluster's NEW CTY badge
|
||||
// answers, for the station being worked right now. Debounced because the
|
||||
// county field is typed into as well as filled by the lookup.
|
||||
const [newCounty, setNewCounty] = useState(false);
|
||||
useEffect(() => {
|
||||
const cnty = (details.cnty ?? '').trim();
|
||||
if (!cnty) { setNewCounty(false); return; }
|
||||
let alive = true;
|
||||
const id = window.setTimeout(async () => {
|
||||
try {
|
||||
const isNew = await IsNewUSCounty(details.state ?? '', cnty);
|
||||
if (alive) setNewCounty(!!isNew);
|
||||
} catch { if (alive) setNewCounty(false); }
|
||||
}, 300);
|
||||
return () => { alive = false; window.clearTimeout(id); };
|
||||
}, [details.state, details.cnty]);
|
||||
useEffect(() => {
|
||||
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
|
||||
const t = window.setTimeout(async () => {
|
||||
@@ -142,6 +179,8 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
const q: any = {
|
||||
callsign, band, mode,
|
||||
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
|
||||
qth: qth ?? '', name: name ?? '', country: country ?? '',
|
||||
comment: comment ?? '', notes: note ?? '', grid: remoteGrid ?? '',
|
||||
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
|
||||
qso_date: new Date().toISOString(),
|
||||
};
|
||||
@@ -151,7 +190,9 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
const have = new Set(cur.split(';').filter(Boolean));
|
||||
let next = cur;
|
||||
for (const r of all) {
|
||||
if (!r.pickable) continue;
|
||||
// An ambiguous candidate is offered, never auto-added: adding one would
|
||||
// be the guess the award's "one reference per QSO" setting exists to refuse.
|
||||
if (!r.pickable || r.ambiguous) continue;
|
||||
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
|
||||
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
|
||||
}
|
||||
@@ -160,7 +201,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
}, 400);
|
||||
return () => window.clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, band, mode]);
|
||||
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, qth, name, country, comment, note, remoteGrid, band, mode]);
|
||||
// Bearing/distance from operator's home grid to the remote station.
|
||||
// Recomputed only when either grid actually changes.
|
||||
const path = useMemo(() => {
|
||||
@@ -227,14 +268,26 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
)}
|
||||
|
||||
{open === 'info' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label={t('detp.statePref')}>
|
||||
<div className="grid grid-cols-12 gap-2 px-3 py-2.5">
|
||||
{/* Twelve columns, not six: the fields hold very different things. A US
|
||||
county name is long, a CQ zone is two digits — on an even six-column
|
||||
grid they were the same width, so the county truncated while the
|
||||
zones sat mostly empty. */}
|
||||
<Field label={t('detp.statePref')} span={2}>
|
||||
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.county')}>
|
||||
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
|
||||
<Field label={t('detp.county')} span={4} className="relative">
|
||||
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} className={cn(newCounty && 'pr-14')} />
|
||||
{newCounty && (
|
||||
<span
|
||||
title={t('detp.newCountyTip')}
|
||||
className="absolute right-1.5 bottom-1.5 rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wide bg-success text-success-foreground pointer-events-none"
|
||||
>
|
||||
{t('detp.newCounty')}
|
||||
</span>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t('detp.prefix')}>
|
||||
<Field label={t('detp.prefix')} span={2}>
|
||||
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
||||
</Field>
|
||||
<Field label={t('detp.cqZone')}>
|
||||
@@ -248,7 +301,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
{/* DXCC # closes the top row (next to the zones); Continent and
|
||||
Azimuth SP live in the main entry strip / bandeau. The long-path
|
||||
bearing and distances move to the row below. */}
|
||||
<Field label={t('detp.dxcc')}>
|
||||
<Field label={t('detp.dxcc')} span={2}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -257,40 +310,45 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.azimuthLp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtDeg(path.bearingLong) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.distanceSp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtKm(path.distanceShort) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.distanceLp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtKm(path.distanceLong) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.address')} span={3}>
|
||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.qslMessage')} span={3}>
|
||||
{/* The bearing and the two distances are read-only and hold at most
|
||||
"12345 km", so they take a fixed width and the address — the one
|
||||
field here that is never wide enough — gets everything left over. */}
|
||||
<div className="col-span-12 flex gap-2">
|
||||
<Field label={t('detp.azimuthLp')} className="w-20 shrink-0">
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtDeg(path.bearingLong) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.distanceSp')} className="w-24 shrink-0">
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtKm(path.distanceShort) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.distanceLp')} className="w-24 shrink-0">
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtKm(path.distanceLong) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.address')} className="flex-1 min-w-0">
|
||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t('detp.qslMessage')} span={7}>
|
||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.qslVia')} span={2}>
|
||||
<Field label={t('detp.qslVia')} span={5}>
|
||||
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
@@ -305,10 +363,37 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
|
||||
heightClass="flex-1 min-h-0"
|
||||
/>
|
||||
{detected.length > 0 && (
|
||||
{/* Ambiguous candidates: the award allows one reference per QSO and
|
||||
several matched, so none was kept. Click one to settle it while the
|
||||
contact is still in front of you. */}
|
||||
{detected.some((r) => r.ambiguous) && (
|
||||
<div className="mt-2 text-[11px] shrink-0 leading-snug border-t border-warning/40 pt-1.5">
|
||||
<span className="font-medium text-warning">{t('detp.ambiguous')}</span>{' '}
|
||||
{detected.filter((r) => r.ambiguous).map((r) => (
|
||||
<button
|
||||
key={`amb-${r.code}@${r.ref}`}
|
||||
type="button"
|
||||
title={r.name ?? ''}
|
||||
onClick={() => {
|
||||
const entry = `${r.code.toUpperCase()}@${r.ref.toUpperCase()}`;
|
||||
const cur = details.award_refs ?? '';
|
||||
// Only one of the candidates can be right — replace any
|
||||
// sibling already picked for this award rather than stacking.
|
||||
const kept = cur.split(';').filter((e) => e && e.split('@')[0].toUpperCase() !== r.code.toUpperCase());
|
||||
onChange({ award_refs: [...kept, entry].join(';') });
|
||||
setDetected((d) => d.filter((x) => !x.ambiguous || x.code.toUpperCase() !== r.code.toUpperCase()));
|
||||
}}
|
||||
className="inline-block mr-1.5 px-1.5 py-0.5 rounded font-mono whitespace-nowrap border border-warning/50 text-warning hover:bg-warning/10"
|
||||
>
|
||||
{r.code}@{r.ref}{r.name ? ` — ${r.name}` : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{detected.some((r) => !r.ambiguous) && (
|
||||
<div className="mt-2 text-[11px] text-muted-foreground shrink-0 max-h-14 overflow-y-auto leading-snug border-t border-border/50 pt-1.5">
|
||||
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
||||
{detected.map((r) => (
|
||||
{detected.filter((r) => !r.ambiguous).map((r) => (
|
||||
<span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
|
||||
<span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
|
||||
</span>
|
||||
|
||||
@@ -323,13 +323,15 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [st.cw_speed]);
|
||||
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
||||
// read steadily instead of jumping every poll.
|
||||
// Peak-hold: keep the highest reading for a second so the jittery VITA-49
|
||||
// meters read steadily instead of jumping every poll. One second, not two —
|
||||
// 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) => {
|
||||
const peakHold = (key: string, val: number, windowMs = 1000) => {
|
||||
const now = Date.now();
|
||||
const p = peak.current[key];
|
||||
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
|
||||
@@ -355,7 +357,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
}, []);
|
||||
|
||||
// PowerGenius XL direct connection (fan mode), independent of the Flex link.
|
||||
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string }>({ connected: false });
|
||||
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string; state?: string; peak_w?: number }>({ connected: false });
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
|
||||
@@ -978,10 +980,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */}
|
||||
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
|
||||
is off (no status = not "connected"), and serial only. */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!spe.connected}
|
||||
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
|
||||
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
|
||||
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
|
||||
@@ -1083,9 +1087,6 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
st.amp_operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{st.amp_operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
||||
</span>
|
||||
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
||||
PowerGenius). The dot shows the direct-connection state; the
|
||||
selector is disabled until connected (hover it for the error). */}
|
||||
@@ -1118,6 +1119,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||
if (off || amp.length === 0) return null;
|
||||
// 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
|
||||
// survives in the last-known status after the amp disconnects — so
|
||||
// it claimed 1350 W from an old transmission while the radio was
|
||||
// putting out 10.
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||
{amp.map((m) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { nightPolygon } from '../lib/greyline';
|
||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
@@ -115,6 +116,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
||||
// across restarts. Default on.
|
||||
const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0');
|
||||
|
||||
// Grey line — the day/night terminator with its twilight band. Off by
|
||||
// default: it is a working tool for the operator who wants it, not decoration
|
||||
// for the one who does not.
|
||||
const [greyline, setGreyline] = useState(() => localStorage.getItem('opslog.mapGreyline') === '1');
|
||||
const greylineLayer = useRef<L.LayerGroup | null>(null);
|
||||
const autoZoomRef = useRef(autoZoom);
|
||||
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
||||
|
||||
@@ -152,6 +159,80 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||
}, [basemap]);
|
||||
|
||||
// Draw the grey line, and keep it moving.
|
||||
//
|
||||
// Redrawn every minute: the terminator travels a quarter of a degree in that
|
||||
// time, which is invisible, but a map left on all day would otherwise be
|
||||
// silently wrong by evening — and the whole point of the display is knowing
|
||||
// where the line is NOW.
|
||||
//
|
||||
// It lives in its own pane below the overlay pane so the path and beam are
|
||||
// never hidden behind the shading, and is non-interactive so it can never
|
||||
// swallow a click meant for the map.
|
||||
useEffect(() => {
|
||||
const m = worldMap.current;
|
||||
if (!m) return;
|
||||
|
||||
if (!greyline) {
|
||||
greylineLayer.current?.remove();
|
||||
greylineLayer.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m.getPane('greyline')) {
|
||||
const pane = m.createPane('greyline');
|
||||
pane.style.zIndex = '350'; // above tiles (200), below overlays (400)
|
||||
pane.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
const now = new Date();
|
||||
greylineLayer.current?.remove();
|
||||
const g = L.layerGroup([], { pane: 'greyline' });
|
||||
|
||||
// The rings span −180…+180 once, but this map repeats the world sideways
|
||||
// (worldCopyJump, and zoomed out several copies are on screen at once). A
|
||||
// single ring therefore ends in a hard vertical edge wherever a copy
|
||||
// begins — the shading looked like a rectangle laid over the planet.
|
||||
//
|
||||
// So each ring is drawn again shifted a full turn either way. Three copies
|
||||
// cover every zoom level this map reaches; they cost nothing, being three
|
||||
// polygons on a canvas layer.
|
||||
const COPIES = [-360, 0, 360];
|
||||
const shifted = (ring: [number, number][], by: number): [number, number][] =>
|
||||
by === 0 ? ring : ring.map(([lat, lon]) => [lat, lon + by] as [number, number]);
|
||||
|
||||
// Two bands, drawn dark-to-light so they read as one gradient into night:
|
||||
// full darkness (Sun below the horizon) and civil twilight (down to −6°),
|
||||
// which is the band operators actually mean by "grey line".
|
||||
const nightRing = nightPolygon(now, 0);
|
||||
const twilightRing = nightPolygon(now, -6);
|
||||
// The terminator itself, so the line is readable at a glance rather than
|
||||
// being inferred from where the shading fades. Dropping the last two
|
||||
// points leaves the terminator alone, without the segment that closes the
|
||||
// polygon along the dark pole.
|
||||
const lineRing = nightRing.slice(0, -2);
|
||||
|
||||
for (const by of COPIES) {
|
||||
L.polygon(shifted(twilightRing, by), {
|
||||
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.22, interactive: false,
|
||||
}).addTo(g);
|
||||
L.polygon(shifted(nightRing, by), {
|
||||
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.32, interactive: false,
|
||||
}).addTo(g);
|
||||
L.polyline(shifted(lineRing, by), {
|
||||
pane: 'greyline', color: '#f59e0b', weight: 1, opacity: 0.75, interactive: false,
|
||||
}).addTo(g);
|
||||
}
|
||||
g.addTo(m);
|
||||
greylineLayer.current = g;
|
||||
};
|
||||
|
||||
draw();
|
||||
const id = window.setInterval(draw, 60_000);
|
||||
return () => { window.clearInterval(id); };
|
||||
}, [greyline]);
|
||||
|
||||
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||
useEffect(() => {
|
||||
const wm = worldMap.current, wo = worldOverlay.current;
|
||||
@@ -293,6 +374,21 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
>
|
||||
Zoom DX
|
||||
</button>
|
||||
{/* Grey line toggle, under the zoom button. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const v = !greyline;
|
||||
setGreyline(v);
|
||||
writeUiPref('opslog.mapGreyline', v ? '1' : '0');
|
||||
}}
|
||||
title={greyline ? 'Grey line is ON — day/night terminator and twilight band' : 'Show the grey line (day/night terminator)'}
|
||||
className={`absolute top-9 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||||
greyline ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
|
||||
}`}
|
||||
>
|
||||
Grey line
|
||||
</button>
|
||||
{path && (
|
||||
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
||||
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
@@ -38,6 +38,12 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
||||
// which used to dismiss the menu the instant it appeared.)
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||
const { t } = useI18n();
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
// Starts at the cursor; the layout effect corrects it once the real size is
|
||||
// known. Rendering at the cursor first avoids a visible jump for the common
|
||||
// case where it already fits.
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
useEffect(() => { if (menu) setPos({ x: menu.x, y: menu.y }); }, [menu]);
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const close = () => onClose();
|
||||
@@ -50,16 +56,40 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
};
|
||||
}, [menu, onClose]);
|
||||
|
||||
// Position AFTER measuring.
|
||||
//
|
||||
// The menu used to be clamped against a guessed height — 230 px when the
|
||||
// upload submenu was present, 110 otherwise. It is far taller than that with
|
||||
// a selection, so right-clicking near the bottom of the window cut half the
|
||||
// entries off. Its height also depends on which actions the caller passes, so
|
||||
// no constant can be right for long.
|
||||
//
|
||||
// Measured instead: if it does not fit below the cursor it is placed above,
|
||||
// and failing that pinned to the top with its own scrollbar.
|
||||
useLayoutEffect(() => {
|
||||
if (!menu || !boxRef.current) return;
|
||||
const r = boxRef.current.getBoundingClientRect();
|
||||
const pad = 6;
|
||||
let x = menu.x;
|
||||
let y = menu.y;
|
||||
if (x + r.width > window.innerWidth - pad) {
|
||||
x = Math.max(pad, window.innerWidth - r.width - pad);
|
||||
}
|
||||
if (y + r.height > window.innerHeight - pad) {
|
||||
// Above the cursor, if there is room there.
|
||||
y = menu.y - r.height >= pad ? menu.y - r.height : Math.max(pad, window.innerHeight - r.height - pad);
|
||||
}
|
||||
setPos({ x, y });
|
||||
}, [menu, onSendTo]);
|
||||
|
||||
if (!menu) return null;
|
||||
const n = menu.ids.length;
|
||||
// Keep the menu on-screen near the cursor.
|
||||
const x = Math.min(menu.x, window.innerWidth - 248);
|
||||
const y = Math.min(menu.y, window.innerHeight - (onSendTo ? 230 : 110));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[200] min-w-[240px] rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
||||
style={{ left: x, top: y }}
|
||||
ref={boxRef}
|
||||
className="fixed z-[200] min-w-[240px] max-h-[85vh] overflow-y-auto rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
||||
style={{ left: pos.x, top: pos.y }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||
import { qslStatusCellClass } from '@/lib/qslStatus';
|
||||
import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||
import type { QSOForm } from '@/types';
|
||||
@@ -85,23 +87,13 @@ function fmtMhzDots(hz?: number): string {
|
||||
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
|
||||
}
|
||||
|
||||
// Both formatters follow the display preference (Settings → General). What is
|
||||
// STORED never changes — see lib/dateFormat.
|
||||
function fmtDateUTC(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return s;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
return formatDateTimeUTC(s);
|
||||
}
|
||||
function fmtDateOnly(s: any): string {
|
||||
if (!s) return '';
|
||||
const t = String(s).trim();
|
||||
// QSL/LoTW/eQSL/ClubLog dates are ADIF YYYYMMDD; upload dates may be ISO.
|
||||
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
||||
const d = new Date(t);
|
||||
if (isNaN(d.getTime())) return t;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
return formatDateOnly(s);
|
||||
}
|
||||
|
||||
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
||||
@@ -178,8 +170,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||
|
||||
// ── QSL ──
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
|
||||
@@ -187,32 +179,32 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
||||
|
||||
// ── LoTW ──
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── eQSL ──
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
|
||||
// ── Uploads (online logbooks) ──
|
||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
||||
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
||||
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
||||
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
@@ -303,7 +295,11 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
||||
|
||||
// Localized column catalog — rebuilt when the language changes.
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
|
||||
// Rebuild the columns when the format changes: the formatters are captured
|
||||
// inside the column definitions, so nothing else would notice.
|
||||
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||
|
||||
// Right-click: if the clicked row isn't already part of the selection,
|
||||
// select just it; then open the bulk-action menu on the whole selection.
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||
GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||
GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted,
|
||||
GetSecretStatus, SetPassphrase, RemovePassphrase,
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
GetQSLDefaults, SaveQSLDefaults,
|
||||
SetWinkeyerTrace,
|
||||
SetCIVTrace,
|
||||
CIVTraceEnabled,
|
||||
WinkeyerTraceEnabled,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
||||
GetPOTAToken, SavePOTAToken,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
@@ -45,7 +47,7 @@ import {
|
||||
DownloadULSCounties, ULSStatus, BackfillUSCounties,
|
||||
ComputeStationInfo,
|
||||
GetUIPref, SetUIPref,
|
||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower,
|
||||
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
@@ -67,6 +69,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
|
||||
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
||||
import { OperatingPanel } from '@/components/OperatingPanel';
|
||||
@@ -950,81 +953,121 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
||||
);
|
||||
}
|
||||
|
||||
// FlexBandAntennasPanel — pick the RX/TX antenna per band. Applied automatically
|
||||
// when the band changes (frequency change / spot click). Antennas come live from
|
||||
// the connected FlexRadio; bands come from Lists → Bands.
|
||||
function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
|
||||
// FlexBandPanel — everything that follows the band, in ONE row per band:
|
||||
// antennas and TX power.
|
||||
//
|
||||
// They were two tables listing the same bands one under the other, which made
|
||||
// the operator match rows by eye between them. The two settings are stored and
|
||||
// applied separately (antennas on band change, power on band or mode change) —
|
||||
// that is a backend detail, and no reason to split what is one decision per
|
||||
// band for the person configuring it.
|
||||
function FlexBandPanel({ bands }: { bands: string[] }) {
|
||||
const { t } = useI18n();
|
||||
const [rxList, setRxList] = useState<string[]>([]);
|
||||
const [txList, setTxList] = useState<string[]>([]);
|
||||
const [map, setMap] = useState<Record<string, { rx: string; tx: string }>>({});
|
||||
const [ant, setAnt] = useState<Record<string, { rx: string; tx: string }>>({});
|
||||
const [pwr, setPwr] = useState<Record<string, { phone: number; cw: number; digi: number }>>({});
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
GetFlexState().then((s: any) => {
|
||||
setRxList((s?.ant_list ?? []) as string[]);
|
||||
setTxList(((s?.tx_ant_list?.length ? s.tx_ant_list : s?.ant_list) ?? []) as string[]);
|
||||
}).catch(() => {});
|
||||
GetFlexBandAntennas().then((m: any) => setMap(m ?? {})).catch(() => {});
|
||||
GetFlexBandAntennas().then((m: any) => setAnt(m ?? {})).catch(() => {});
|
||||
GetFlexBandPower().then((m: any) => setPwr(m ?? {})).catch(() => {});
|
||||
}, []);
|
||||
const set = (band: string, side: 'rx' | 'tx', v: string) => {
|
||||
|
||||
const saved = () => { setMsg(t('flxpw.saved')); window.setTimeout(() => setMsg(''), 1200); };
|
||||
|
||||
const setAntenna = (band: string, side: 'rx' | 'tx', v: string) => {
|
||||
const key = band.toUpperCase();
|
||||
setMap((m) => {
|
||||
const cur = m[key] ?? { rx: '', tx: '' };
|
||||
const next = { ...m, [key]: { ...cur, [side]: v } };
|
||||
SaveFlexBandAntennas(next as any)
|
||||
.then(() => { setMsg('Saved'); window.setTimeout(() => setMsg(''), 1200); })
|
||||
.catch((e: any) => setMsg(String(e?.message ?? e)));
|
||||
setAnt((m) => {
|
||||
const next = { ...m, [key]: { ...(m[key] ?? { rx: '', tx: '' }), [side]: v } };
|
||||
SaveFlexBandAntennas(next as any).then(saved).catch((e: any) => setMsg(String(e?.message ?? e)));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const setPower = (band: string, kind: 'phone' | 'cw' | 'digi', v: string) => {
|
||||
const key = band.toUpperCase();
|
||||
const n = Math.max(0, Math.min(100, parseInt(v, 10) || 0));
|
||||
setPwr((m) => {
|
||||
const next = { ...m, [key]: { ...(m[key] ?? { phone: 0, cw: 0, digi: 0 }), [kind]: n } };
|
||||
SaveFlexBandPower(next as any).then(saved).catch((e: any) => setMsg(String(e?.message ?? e)));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const antCell = (b: string, side: 'rx' | 'tx', list: string[]) => {
|
||||
const e = ant[b.toUpperCase()] ?? { rx: '', tx: '' };
|
||||
return (
|
||||
<td className="px-2 py-1.5">
|
||||
<select value={e[side] ?? ''} onChange={(ev) => setAntenna(b, side, ev.target.value)}
|
||||
className="h-8 w-28 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
|
||||
<option value="">— none —</option>
|
||||
{list.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
);
|
||||
};
|
||||
|
||||
// The rule marking where band-driven settings end and mode-driven ones begin
|
||||
// is drawn on the FIRST power cell, header and body alike. It used to be an
|
||||
// extra empty cell in the body only, which gave the two rows different column
|
||||
// counts and slid every heading one column left.
|
||||
const pwrCell = (b: string, kind: 'phone' | 'cw' | 'digi') => {
|
||||
const e = pwr[b.toUpperCase()] ?? { phone: 0, cw: 0, digi: 0 };
|
||||
return (
|
||||
<td className={cn('px-2 py-1.5', kind === 'phone' && 'border-l border-border/60')}>
|
||||
<Input
|
||||
type="number" min={0} max={100}
|
||||
className="h-8 w-16 text-xs"
|
||||
value={e[kind] ? String(e[kind]) : ''}
|
||||
placeholder="—"
|
||||
onChange={(ev) => setPower(b, kind, ev.target.value)}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">FlexRadio — per-band antennas</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose the RX and TX antenna for each band. They're applied automatically when the band
|
||||
changes (frequency change or clicking a spot).
|
||||
</p>
|
||||
<h3 className="text-base font-semibold text-foreground">{t('flxb.title')}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t('flxb.hint')}</p>
|
||||
</div>
|
||||
{rxList.length === 0 && (
|
||||
<div className="text-xs text-warning-muted-foreground bg-warning-muted border border-warning-border rounded-md px-3 py-2 max-w-xl">
|
||||
No antennas reported yet — make sure the FlexRadio is connected (CAT interface), then reopen this panel.
|
||||
<div className="text-xs text-warning-muted-foreground bg-warning-muted border border-warning-border rounded-md px-3 py-2 max-w-2xl">
|
||||
{t('flxb.noRadio')}
|
||||
</div>
|
||||
)}
|
||||
{bands.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No bands configured — add them in Lists → Bands.</p>
|
||||
<p className="text-xs text-muted-foreground">{t('flxpw.noBands')}</p>
|
||||
) : (
|
||||
<div className="rounded-md border border-border overflow-hidden max-w-xl">
|
||||
<div className="rounded-md border border-border overflow-x-auto max-w-3xl">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-muted-foreground text-xs">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-1.5 font-semibold">Band</th>
|
||||
<th className="text-left px-3 py-1.5 font-semibold">RX antenna</th>
|
||||
<th className="text-left px-3 py-1.5 font-semibold">TX antenna</th>
|
||||
<th className="text-left px-3 py-1.5 font-semibold">{t('flxpw.band')}</th>
|
||||
<th className="text-left px-2 py-1.5 font-semibold">{t('flxb.rxAnt')}</th>
|
||||
<th className="text-left px-2 py-1.5 font-semibold">{t('flxb.txAnt')}</th>
|
||||
<th className="text-left px-2 py-1.5 font-semibold border-l border-border/60">{t('flxpw.phone')}</th>
|
||||
<th className="text-left px-2 py-1.5 font-semibold">CW</th>
|
||||
<th className="text-left px-2 py-1.5 font-semibold">{t('flxpw.digi')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bands.map((b) => {
|
||||
const e = map[b.toUpperCase()] ?? { rx: '', tx: '' };
|
||||
return (
|
||||
<tr key={b} className="border-t border-border/50">
|
||||
<td className="px-3 py-1.5 font-mono font-semibold">{b}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<select value={e.rx ?? ''} onChange={(ev) => set(b, 'rx', ev.target.value)}
|
||||
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
|
||||
<option value="">— none —</option>
|
||||
{rxList.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<select value={e.tx ?? ''} onChange={(ev) => set(b, 'tx', ev.target.value)}
|
||||
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
|
||||
<option value="">— none —</option>
|
||||
{txList.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{bands.map((b) => (
|
||||
<tr key={b} className="border-t border-border/50">
|
||||
<td className="px-3 py-1.5 font-mono font-semibold">{b}</td>
|
||||
{antCell(b, 'rx', rxList)}
|
||||
{antCell(b, 'tx', txList)}
|
||||
{pwrCell(b, 'phone')}
|
||||
{pwrCell(b, 'cw')}
|
||||
{pwrCell(b, 'digi')}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1093,7 +1136,7 @@ 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, kenwood_port: '', kenwood_baud: 9600, xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70,
|
||||
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: '',
|
||||
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,
|
||||
@@ -1136,8 +1179,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
});
|
||||
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
||||
// Session-only: the byte trace is a diagnostic, never a saved preference.
|
||||
//
|
||||
// But it must still SHOW its real state. These start false on every mount, so
|
||||
// a trace already running came back unticked; the operator ticks the box to
|
||||
// turn it on, which turns it off, and the log they send has no trace in it.
|
||||
// Reported by a Xiegu user. Hydrated from the backend below.
|
||||
const [dateFmtSel, setDateFmtSel] = useState<DateFormat>(getDateFormat);
|
||||
const [wkTrace, setWkTrace] = useState(false);
|
||||
const [civTrace, setCivTrace] = useState(false);
|
||||
useEffect(() => {
|
||||
CIVTraceEnabled().then((v) => setCivTrace(!!v)).catch(() => {});
|
||||
WinkeyerTraceEnabled().then((v) => setWkTrace(!!v)).catch(() => {});
|
||||
}, []);
|
||||
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
||||
|
||||
// ── Audio (DVK + QSO recorder) ──
|
||||
@@ -1145,13 +1198,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
||||
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
||||
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
|
||||
from_gain: number; mic_gain: number; tx_gain: number;
|
||||
from_gain: number; mic_gain: number; tx_gain: number; qso_play_gain: number;
|
||||
};
|
||||
type AudioDev = { id: string; name: string; default: boolean };
|
||||
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
||||
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
||||
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
|
||||
from_gain: 100, mic_gain: 100, tx_gain: 100,
|
||||
from_gain: 100, mic_gain: 100, tx_gain: 100, qso_play_gain: 100,
|
||||
});
|
||||
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
|
||||
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
|
||||
@@ -1276,7 +1329,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
upload_flags: string[]; write_log: boolean;
|
||||
auto_upload: boolean; upload_mode: string;
|
||||
};
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg };
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; delete_remote?: boolean };
|
||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||
@@ -1284,7 +1337,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
auto_upload: false, upload_mode: 'immediate',
|
||||
});
|
||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(),
|
||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), delete_remote: false,
|
||||
});
|
||||
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [qrzTesting, setQrzTesting] = useState(false);
|
||||
@@ -2485,6 +2538,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
}} />
|
||||
<span className="text-xs text-muted-foreground">{t('cat.xieguAddrHint')}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.xieguPTTLine')}</Label>
|
||||
<Select value={catCfg.xiegu_ptt_line || 'civ'}
|
||||
onValueChange={(v) => setCatCfg((s) => ({ ...s, xiegu_ptt_line: v === 'civ' ? '' : v }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="civ">{t('cat.xieguPTTCiv')}</SelectItem>
|
||||
<SelectItem value="rts">RTS</SelectItem>
|
||||
<SelectItem value="dtr">DTR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground">{t('cat.xieguPTTHint')}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'yaesu' && (
|
||||
@@ -2514,9 +2580,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground">{t('cat.yaesuBaudHint')}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<Checkbox checked={!!catCfg.yaesu_low_lines} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, yaesu_low_lines: !!c }))} />
|
||||
{t('cat.lowerLines')}
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(catCfg.backend === 'icom' || catCfg.backend === 'xiegu') && (
|
||||
{['icom', 'xiegu', 'kenwood'].includes(catCfg.backend) && (
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={civTrace} onCheckedChange={(c) => { setCivTrace(!!c); SetCIVTrace(!!c); }} />
|
||||
@@ -2552,6 +2625,22 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground">{t('cat.kenwoodBaudHint')}</span>
|
||||
</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 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 }))} />
|
||||
{t('cat.lowerLines')}
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'icom' && (
|
||||
@@ -3209,6 +3298,7 @@ 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>
|
||||
</>
|
||||
);
|
||||
@@ -4203,6 +4293,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
|
||||
<ProfileScopeNote profile={activeProfileObj} />
|
||||
|
||||
{/* Applies to BOTH services, so it sits above the tab strip rather than
|
||||
inside one tab where it would look like a QRZ-only setting (and be
|
||||
invisible to anyone who only opens the Club Log tab). */}
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer mb-4 max-w-2xl">
|
||||
<Checkbox
|
||||
checked={!!extSvc.delete_remote}
|
||||
onCheckedChange={(c) => setExtSvc((v) => ({ ...v, delete_remote: !!c }))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{t('es.deleteRemote')}
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">{t('es.deleteRemoteHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Tab strip */}
|
||||
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
|
||||
{TABS.map((tab) => (
|
||||
@@ -5038,13 +5143,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Label className="text-sm">{t('aud.fromLevel')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
|
||||
onChange={(e) => setAudioField({ from_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
|
||||
onChange={(e) => { const v = parseInt(e.target.value, 10); setAudioField({ from_gain: v }); AudioApplyLevels(v, audioCfg.mic_gain); }} className="w-48 accent-primary" />
|
||||
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
|
||||
</div>
|
||||
<Label className="text-sm">{t('aud.qsoPlayLevel')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="range" min={10} max={300} step={5} value={audioCfg.qso_play_gain}
|
||||
onChange={(e) => setAudioField({ qso_play_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
|
||||
<span className="font-mono text-xs w-12 text-right">{audioCfg.qso_play_gain}%</span>
|
||||
</div>
|
||||
<Label className="text-sm">{t('aud.micLevel')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
|
||||
onChange={(e) => setAudioField({ mic_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
|
||||
onChange={(e) => { const v = parseInt(e.target.value, 10); setAudioField({ mic_gain: v }); AudioApplyLevels(audioCfg.from_gain, v); }} className="w-48 accent-primary" />
|
||||
<span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5187,6 +5298,27 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display only. The log is stored in ISO/ADIF whatever is chosen here
|
||||
— that is what ADIF specifies and what sorts correctly, and a
|
||||
stored format that followed a preference would make the log
|
||||
unreadable the day the preference changed. */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="text-sm w-40">{t('gen.dateFormat')}</Label>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{([['iso', t('gen.dateStandard'), '2026-07-30 14:25'],
|
||||
['fr', t('gen.dateFR'), '30-07-2026 14:25'],
|
||||
['us', t('gen.dateUS'), '07-30-2026 14:25']] as [DateFormat, string, string][]).map(([code, label, sample]) => (
|
||||
<button key={code} type="button" title={sample}
|
||||
onClick={() => { setDateFormat(code); setDateFmtSel(code); }}
|
||||
className={cn('flex flex-col items-start px-3 py-1 text-sm font-medium border-l border-border first:border-l-0',
|
||||
dateFmtSel === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{label}
|
||||
<span className="text-[10px] font-mono opacity-70">{sample}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ThemeSelector />
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
@@ -5533,7 +5665,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
antgenius: AntGeniusPanelSettings,
|
||||
tunergenius: TunerGeniusPanelSettings,
|
||||
pgxl: PGXLPanelSettings,
|
||||
flex: () => <FlexBandAntennasPanel bands={lists.bands ?? []} />,
|
||||
flex: () => <FlexBandPanel bands={lists.bands ?? []} />,
|
||||
audio: AudioPanel,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -26,13 +26,22 @@ type Device = { id: string; type: string; name: string; host: string; user?: str
|
||||
type Relay = { number: number; label: string; on: boolean };
|
||||
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||
|
||||
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)' };
|
||||
// Dashboard geometry: a grid of EQUAL columns whose cards also share a common
|
||||
// HEIGHT per row — every card stretches to the tallest one beside it. Masonry
|
||||
// packed tighter but left the row edges ragged, which read as untidy on a wide
|
||||
// screen; aligned rows are what an operator expects from a dashboard.
|
||||
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, 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('') };
|
||||
@@ -269,6 +278,23 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
// Max columns per row (persisted). "Auto" fills the window; a number caps the
|
||||
// row so cards wrap onto further lines even when there's horizontal room.
|
||||
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
||||
|
||||
// How many columns the dashboard shows. "Auto" fits as many CARD_MIN-wide
|
||||
// cards as the window allows; an explicit count is honoured as asked, even
|
||||
// past that width — the operator can see his own screen.
|
||||
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||
const [colCount, setColCount] = useState(1);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (cols !== 'auto') { setColCount(Number(cols) || 1); return; }
|
||||
const el = gridRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => setColCount(Math.max(1, Math.floor((el.clientWidth + GRID_GAP) / (CARD_MIN + GRID_GAP))));
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [cols]);
|
||||
// Amplifiers (Settings → Amplifier): shown here so operators without a
|
||||
// FlexRadio panel still get the controls. EVERY configured amp gets its own
|
||||
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
|
||||
@@ -415,10 +441,10 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
);
|
||||
};
|
||||
|
||||
// `wide` cards span the whole dashboard instead of sitting in one masonry
|
||||
// column. The amplifier and tuner carry meter rows and a channel selector that
|
||||
// are unreadable squeezed into a ~430px column — they are the same cards the
|
||||
// FlexRadio panel shows full-width, and they need that room here too.
|
||||
// `wide` cards take two grid columns instead of one. The amplifier and tuner
|
||||
// carry meter rows and a channel selector that are unreadable squeezed into a
|
||||
// single ~430px column — they are the same cards the FlexRadio panel shows
|
||||
// full-width, and they need that room here too.
|
||||
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
|
||||
if (rot.enabled) {
|
||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||
@@ -446,7 +472,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
|
||||
card can sit on a further line even with horizontal room to spare. */}
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
||||
{(['auto', '1', '2', '3', '4'] as const).map((c) => (
|
||||
{(['auto', '1', '2', '3', '4', '5', '6'] as const).map((c) => (
|
||||
<button key={c} type="button"
|
||||
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
||||
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||
@@ -466,31 +492,36 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Masonry dashboard: fixed-width cards flow into balanced CSS columns so
|
||||
they pack tightly by height (no ragged gaps under short cards). "Auto"
|
||||
fits as many ~430px columns as the window allows; a fixed count caps the
|
||||
container width to that many columns. Each card has a grip handle (left
|
||||
rail) as the drag initiator (the body is full of buttons). */}
|
||||
<div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance',
|
||||
...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}>
|
||||
{/* Dashboard grid: equal columns, and every card stretches to the height of
|
||||
the tallest one on its row, so the rows line up instead of ending
|
||||
ragged. One column each, two for the amplifier/tuner whose meter rows
|
||||
need the room. Each card has a grip handle (left rail) as the drag
|
||||
initiator, since the body is full of buttons. */}
|
||||
<div ref={gridRef} style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
|
||||
gap: `${GRID_GAP}px`,
|
||||
}}>
|
||||
{ordered.map((w) => (
|
||||
// column-span:all lifts a wide card out of the columns and across the
|
||||
// full container, while keeping it in document order — so drag-reorder
|
||||
// still works between wide and normal cards. Capped so it stays a card
|
||||
// and not a banner on an ultra-wide window.
|
||||
<div key={w.id} className="flex items-stretch break-inside-avoid mb-4"
|
||||
style={w.wide ? { columnSpan: 'all', maxWidth: '900px' } : undefined}
|
||||
<div key={w.id}
|
||||
// A wide card takes two columns — but never more than there are, or
|
||||
// the grid would grow a phantom column on a narrow window.
|
||||
style={{ gridColumn: w.wide ? `span ${Math.min(2, colCount)}` : undefined }}
|
||||
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
||||
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
||||
<div draggable
|
||||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragEnd={() => { dragId.current = null; }}
|
||||
title={t('station.dragMove')}
|
||||
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
|
||||
dragId.current === w.id && 'opacity-60')}>
|
||||
<GripVertical className="size-4" />
|
||||
{/* h-full down the chain is what makes the card fill the row height
|
||||
rather than sit at its natural size in a taller cell. */}
|
||||
<div className="flex items-stretch h-full">
|
||||
<div draggable
|
||||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragEnd={() => { dragId.current = null; }}
|
||||
title={t('station.dragMove')}
|
||||
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
|
||||
dragId.current === w.id && 'opacity-60')}>
|
||||
<GripVertical className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 [&>*]:h-full">{w.node}</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">{w.node}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -523,6 +554,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.
|
||||
@@ -573,6 +605,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>
|
||||
@@ -583,13 +616,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>
|
||||
@@ -632,11 +665,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">
|
||||
|
||||
@@ -76,13 +76,16 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
|
||||
const peakHold = (key: string, val: number) => {
|
||||
const now = Date.now();
|
||||
const p = peak.current[key];
|
||||
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
|
||||
const connected = !!status.connected;
|
||||
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||
const rawFwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0;
|
||||
// Prefer the device's own peak detector over the sampled reading; the JS hold
|
||||
// below still smooths what is left between polls.
|
||||
const rawPeak = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
|
||||
const rawFwdW = rawPeak >= 1 ? rawPeak : 0;
|
||||
const fwdW = peakHold('fwd', rawFwdW);
|
||||
|
||||
// SWR needs RF to mean anything: sampled on receive the device reports a
|
||||
@@ -93,13 +96,14 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
|
||||
// That expiry is the point: the first version of this held the last TX value
|
||||
// with no timeout at all, so the meter sat frozen on the previous transmission
|
||||
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
|
||||
// Same 2 s window as the power peak above, so the two meters clear together.
|
||||
// Same one-second window as the power peak above, so the two meters clear
|
||||
// together.
|
||||
const swrHold = useRef<{ v: number; t: number } | null>(null);
|
||||
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
|
||||
const heldSwr = swrHold.current;
|
||||
const vswr = rawFwdW >= 1
|
||||
? rawVswr
|
||||
: heldSwr && Date.now() - heldSwr.t < 2000
|
||||
: heldSwr && Date.now() - heldSwr.t < 1000
|
||||
? heldSwr.v
|
||||
: undefined;
|
||||
const active = status.active ?? 1;
|
||||
|
||||
@@ -8,7 +8,7 @@ export type TGChannel = {
|
||||
};
|
||||
export type TGStatus = {
|
||||
connected: boolean; host?: string; last_error?: string;
|
||||
fwd_dbm?: number; fwd_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
|
||||
fwd_dbm?: number; fwd_w?: number; peak_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
|
||||
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
|
||||
antenna?: number; three_way?: boolean; message?: string;
|
||||
a?: TGChannel; b?: TGChannel;
|
||||
@@ -69,6 +69,10 @@ export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActiva
|
||||
const { t } = useI18n();
|
||||
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||
const active = status.active ?? 1;
|
||||
// The device's own peak, not the instantaneous reading: a 400 ms poll lands
|
||||
// between syllables as often as on a peak, so the plain figure showed a few
|
||||
// hundred watts on a kilowatt transmission.
|
||||
const fwdW = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||
@@ -103,7 +107,7 @@ export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActiva
|
||||
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
|
||||
<div className="text-lg font-bold font-mono leading-tight text-foreground/80">
|
||||
{status.fwd_w && status.fwd_w >= 1 ? `${Math.round(status.fwd_w)} W` : '—'}
|
||||
{fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||||
import { getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -63,7 +64,9 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
|
||||
// Localized column catalog (shared with the Recent QSOs grid).
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
|
||||
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
|
||||
@@ -37,7 +37,11 @@ const TabsContent = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', className)}
|
||||
// No focus ring on the panel itself. Radix makes the content focusable and
|
||||
// moves focus into it when a tab is selected, so clicking anywhere in the
|
||||
// lower half drew a full-width orange rule under the tab strip. The panel is
|
||||
// a container — what it holds carries its own focus styling.
|
||||
className={cn('mt-2 focus-visible:outline-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// How dates are DISPLAYED. Nothing here touches what is stored.
|
||||
//
|
||||
// The log keeps ADIF/ISO throughout — the database, ADIF export, LoTW, every
|
||||
// upload. This is the reading layer only, because an operator reading
|
||||
// "2026-07-30" as the 7th of the 30th month is reading their own log wrongly,
|
||||
// and that is a display problem, not a data one.
|
||||
//
|
||||
// Storage stays ISO deliberately and permanently: it sorts correctly as text,
|
||||
// it is what ADIF specifies, and a log whose stored format followed a UI
|
||||
// preference would become unreadable the day the preference changed.
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
export type DateFormat = 'iso' | 'fr' | 'us';
|
||||
|
||||
const KEY = 'opslog.dateFormat';
|
||||
|
||||
function read(): DateFormat {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return v === 'fr' || v === 'us' ? v : 'iso';
|
||||
}
|
||||
|
||||
let current: DateFormat = read();
|
||||
|
||||
// A minimal store so every grid and panel re-renders the moment the format
|
||||
// changes, rather than showing the old one until the next restart.
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getDateFormat(): DateFormat {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function setDateFormat(f: DateFormat): void {
|
||||
current = f;
|
||||
writeUiPref(KEY, f);
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function subscribeDateFormat(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
// reloadDateFormat re-reads the stored value — for after the portable prefs
|
||||
// have been synced from the database at startup, which happens AFTER this
|
||||
// module's first read.
|
||||
export function reloadDateFormat(): void {
|
||||
const v = read();
|
||||
if (v !== current) {
|
||||
current = v;
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
// order arranges the three parts. Separators stay '-' in every format: a slash
|
||||
// in the French one would be conventional but it also invites the reader to
|
||||
// take it for a US date, which is exactly the confusion being fixed.
|
||||
function order(y: number, m: number, d: number): string {
|
||||
switch (current) {
|
||||
case 'fr':
|
||||
return `${pad(d)}-${pad(m)}-${y}`;
|
||||
case 'us':
|
||||
return `${pad(m)}-${pad(d)}-${y}`;
|
||||
}
|
||||
return `${y}-${pad(m)}-${pad(d)}`;
|
||||
}
|
||||
|
||||
// formatDateTimeUTC renders a timestamp with the time, in UTC.
|
||||
//
|
||||
// UTC is not negotiable and is not part of the preference: a logbook is kept in
|
||||
// UTC, and a date shown in local time would disagree with the QSO's own record
|
||||
// twice a year.
|
||||
export function formatDateTimeUTC(s: unknown): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(String(s));
|
||||
if (isNaN(d.getTime())) return String(s);
|
||||
return `${order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
// formatDateOnly renders a date with no time. It accepts both the ADIF form
|
||||
// (YYYYMMDD, used by every QSL and upload date) and anything Date can parse.
|
||||
export function formatDateOnly(s: unknown): string {
|
||||
if (!s) return '';
|
||||
const t = String(s).trim();
|
||||
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||
if (m) return order(Number(m[1]), Number(m[2]), Number(m[3]));
|
||||
const d = new Date(t);
|
||||
if (isNaN(d.getTime())) return t;
|
||||
return order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Grey line — the day/night terminator, and the twilight band around it where
|
||||
// HF propagation briefly does things it does at no other time.
|
||||
//
|
||||
// The maths is the standard low-precision solar position (Meeus, chapter 25, as
|
||||
// used by NOAA's solar calculator): good to well under a degree, which is far
|
||||
// finer than the band being drawn. No ephemeris, no network, no dependency —
|
||||
// this has to work in a field shack with no internet.
|
||||
//
|
||||
// Everything here is pure so it can be tested against known positions; the
|
||||
// drawing lives in the map component.
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
// julianDay for a date, including the fraction of the day.
|
||||
export function julianDay(d: Date): number {
|
||||
return d.getTime() / 86400000 + 2440587.5;
|
||||
}
|
||||
|
||||
// Sun's declination and Greenwich hour angle, both in degrees.
|
||||
//
|
||||
// Returned together because they are computed from the same intermediates and
|
||||
// every caller needs both — splitting them into two exported functions would
|
||||
// double the work at each call for the sake of a tidier signature.
|
||||
export function sunPosition(date: Date): { dec: number; gha: number } {
|
||||
const jd = julianDay(date);
|
||||
const n = jd - 2451545.0; // days from J2000.0
|
||||
|
||||
// Mean longitude and mean anomaly of the Sun.
|
||||
const L = (280.460 + 0.9856474 * n) % 360;
|
||||
const g = ((357.528 + 0.9856003 * n) % 360) * DEG;
|
||||
|
||||
// Ecliptic longitude: the equation of centre applied to the mean longitude.
|
||||
const lambda = (L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * DEG;
|
||||
|
||||
// Obliquity of the ecliptic, slowly decreasing.
|
||||
const eps = (23.439 - 0.0000004 * n) * DEG;
|
||||
|
||||
const dec = Math.asin(Math.sin(eps) * Math.sin(lambda)) / DEG;
|
||||
|
||||
// Right ascension, kept in the same quadrant as the ecliptic longitude —
|
||||
// atan2 alone lands 180° out for half the year, which mirrors the whole
|
||||
// terminator about the prime meridian. Six months of a plausible-looking
|
||||
// wrong answer is exactly the kind of bug that survives review.
|
||||
let ra = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)) / DEG;
|
||||
if (ra < 0) ra += 360;
|
||||
|
||||
// Greenwich mean sidereal time, in degrees.
|
||||
const gmst = (280.46061837 + 360.98564736629 * n) % 360;
|
||||
|
||||
let gha = gmst - ra;
|
||||
gha = ((gha % 360) + 360) % 360;
|
||||
return { dec, gha };
|
||||
}
|
||||
|
||||
// terminatorLatitude returns the latitude, at a given longitude, where the Sun
|
||||
// sits at `altitude` degrees above (or below) the horizon.
|
||||
//
|
||||
// altitude 0 is the geometric terminator; -6 is civil twilight, the outer edge
|
||||
// of the grey line as operators use the term.
|
||||
//
|
||||
// Returns null where no such latitude exists at that longitude — inside the
|
||||
// polar day or polar night, where the Sun never crosses that altitude at all.
|
||||
export function terminatorLatitude(lonDeg: number, dec: number, gha: number, altitude = 0): number | null {
|
||||
const ha = (gha + lonDeg) * DEG; // local hour angle
|
||||
const d = dec * DEG;
|
||||
const h = altitude * DEG;
|
||||
|
||||
// From sin(alt) = sin(dec)sin(lat) + cos(dec)cos(lat)cos(ha), solved for lat:
|
||||
// A·sin(lat) + B·cos(lat) = sin(alt), A = sin(dec), B = cos(dec)cos(ha)
|
||||
//
|
||||
// That has TWO solutions per meridian, and picking the wrong one is not a
|
||||
// small error — it mirrors the answer about the equator. New York at 02:00
|
||||
// local in December came out as daylight because the branch chosen put the
|
||||
// terminator at +63° where it belongs at −63°: a shaded map that looks
|
||||
// entirely plausible and is exactly wrong.
|
||||
//
|
||||
// So anchor on the geometric terminator, where the answer is unambiguous and
|
||||
// classical, and take whichever branch is nearest it. At altitude 0 that
|
||||
// reproduces it exactly; for the twilight band it follows it by continuity,
|
||||
// which is the physical requirement — the twilight curve is a small offset
|
||||
// from the terminator, never on the other side of the planet.
|
||||
const A = Math.sin(d);
|
||||
const B = Math.cos(d) * Math.cos(ha);
|
||||
const R = Math.hypot(A, B);
|
||||
if (R === 0) return null;
|
||||
const s = Math.sin(h) / R;
|
||||
if (s < -1 || s > 1) return null; // the Sun never reaches this altitude here
|
||||
|
||||
// The classical terminator. Division by zero is deliberate and correct here:
|
||||
// at an equinox tan(dec) → 0 and the terminator becomes a meridian, which is
|
||||
// exactly what atan(±Infinity) = ±90° expresses.
|
||||
const t = Math.tan(d);
|
||||
if (t === 0 && Math.cos(ha) === 0) return null; // degenerate: no crossing to name
|
||||
const lat0 = Math.atan(-Math.cos(ha) / t) / DEG;
|
||||
|
||||
const phi = Math.atan2(B, A); // phase of the A·sin(lat) + B·cos(lat) sum
|
||||
const fold = (x: number) => {
|
||||
let v = ((x + 180) % 360 + 360) % 360 - 180;
|
||||
if (v > 90) v = 180 - v;
|
||||
if (v < -90) v = -180 - v;
|
||||
return v;
|
||||
};
|
||||
const c1 = fold((Math.asin(s) - phi) / DEG);
|
||||
const c2 = fold((Math.PI - Math.asin(s) - phi) / DEG);
|
||||
return Math.abs(c1 - lat0) <= Math.abs(c2 - lat0) ? c1 : c2;
|
||||
}
|
||||
|
||||
// nightPolygon returns a ring covering the part of the world where the Sun is
|
||||
// below `altitude`, ready for L.polygon (as [lat, lon] pairs).
|
||||
//
|
||||
// The ring runs along the terminator from west to east and closes along
|
||||
// whichever pole is in darkness — which pole that is flips with the season, and
|
||||
// getting it wrong shades the lit half of the planet.
|
||||
export function nightPolygon(date: Date, altitude = 0, stepDeg = 2): [number, number][] {
|
||||
const { dec, gha } = sunPosition(date);
|
||||
const ring: [number, number][] = [];
|
||||
|
||||
for (let lon = -180; lon <= 180; lon += stepDeg) {
|
||||
const lat = terminatorLatitude(lon, dec, gha, altitude);
|
||||
// Inside a polar day/night there is no crossing; clamp to the pole so the
|
||||
// ring stays closed rather than tearing open across the map.
|
||||
ring.push([lat === null ? (dec > 0 ? -90 : 90) : lat, lon]);
|
||||
}
|
||||
|
||||
// Northern summer (positive declination) → the SOUTH pole is dark.
|
||||
const pole = dec > 0 ? -90 : 90;
|
||||
ring.push([pole, 180]);
|
||||
ring.push([pole, -180]);
|
||||
return ring;
|
||||
}
|
||||
+38
-26
File diff suppressed because one or more lines are too long
@@ -0,0 +1,41 @@
|
||||
// Colouring for QSL / confirmation status letters.
|
||||
//
|
||||
// These columns are read by COLOUR before they are read as letters: an operator
|
||||
// scanning a hundred rows wants to see where the gaps are, not to read "Y" a
|
||||
// hundred times.
|
||||
//
|
||||
// Colour ONLY — no pill, no badge. These values sit among callsigns and dates,
|
||||
// and a column of coloured boxes would shout louder than the callsign it
|
||||
// belongs to.
|
||||
//
|
||||
// The classes are semantic tokens, not fixed colours, so all four themes follow
|
||||
// and the contrast stays right on the light ones.
|
||||
|
||||
// ADIF confirmation values, and what they mean to the operator:
|
||||
// Y confirmed / sent
|
||||
// N not sent, not received
|
||||
// R requested — queued, waiting on the other station or the service
|
||||
// I ignore (ADIF's "invalid/ignore"), left in default ink: it is neither
|
||||
// good news nor bad, and colouring it would put it in one camp or the
|
||||
// other.
|
||||
//
|
||||
// Anything else — a blank, or a status some other logger wrote — is left alone
|
||||
// rather than guessed at.
|
||||
export function qslStatusClass(value: unknown): string {
|
||||
switch (String(value ?? '').trim().toUpperCase()) {
|
||||
case 'Y':
|
||||
return 'text-success';
|
||||
case 'N':
|
||||
return 'text-destructive';
|
||||
case 'R':
|
||||
return 'text-info';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// qslStatusCellClass is the AG-Grid form: same rule, plus the monospace the
|
||||
// status columns already used so the letters stay in a straight column.
|
||||
export function qslStatusCellClass(p: { value?: unknown }): string {
|
||||
const c = qslStatusClass(p?.value);
|
||||
return c ? 'font-mono ' + c : 'font-mono';
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
// folder (data/) to another machine. These helpers mirror them into the DB
|
||||
// settings table (ui.* keys, like the grid columns) so the whole setup is
|
||||
// identical after a copy.
|
||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||
import { GetUIPref, SetUIPref, LogUIError } from '../../wailsjs/go/main/App';
|
||||
|
||||
// Keys that must travel with data/ (DB is the portable source of truth; the
|
||||
// localStorage copy is just a fast, synchronous cache).
|
||||
@@ -17,6 +17,8 @@ const PORTABLE_KEYS = [
|
||||
'opslog.autofocusWB', // auto-focus Worked-before
|
||||
'hamlog.filterPresets', // Filter Builder saved presets
|
||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||
'opslog.showAmpWidget', // amplifier widget shown next to the keyers
|
||||
'opslog.ampSel.widget', // which amplifier that widget shows ("all" or an amp id)
|
||||
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
||||
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
|
||||
@@ -28,11 +30,15 @@ const PORTABLE_KEYS = [
|
||||
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||
'opslog.dateFormat', // how dates are DISPLAYED (iso / fr / us); storage stays ISO
|
||||
'opslog.mapGreyline', // world map: grey line (day/night terminator) shown
|
||||
'opslog.awardRefSort', 'opslog.awardRefSortDir', // award reference table: sort column and direction
|
||||
// Cluster filter selections — restored on reopen.
|
||||
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||
'opslog.activeTab', // last selected tab
|
||||
'opslog.mainSplit', // Main tab: width share of the left pane (percent)
|
||||
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||
@@ -62,5 +68,11 @@ export async function syncPortablePrefs(): Promise<void> {
|
||||
// Use it everywhere these keys are written instead of localStorage.setItem.
|
||||
export function writeUiPref(key: string, value: string): void {
|
||||
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
|
||||
SetUIPref(key, value).catch(() => { /* DB unavailable — the cache still holds it */ });
|
||||
SetUIPref(key, value).catch((e: any) => {
|
||||
// The cache still holds it, so the interface behaves — but the BACKEND
|
||||
// reads some of these keys too (digital-mode grouping decides how the
|
||||
// cluster judges a slot). A write that reaches localStorage and not the
|
||||
// database makes the two disagree, and used to do so in silence.
|
||||
try { LogUIError("ui pref", "could not store " + key + ": " + String(e?.message ?? e), ""); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { syncPortablePrefs } from './lib/uiPref'
|
||||
import { I18nProvider } from './lib/i18n'
|
||||
import { ThemeProvider, initTheme } from './lib/theme'
|
||||
import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary'
|
||||
import { reloadDateFormat } from './lib/dateFormat'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
@@ -18,6 +19,10 @@ syncPortablePrefs().finally(() => {
|
||||
// Stamp the persisted theme onto <html> before render so the correct
|
||||
// palette is applied immediately (portable pref hydrated just above).
|
||||
initTheme()
|
||||
// The date format module read localStorage at import time, which is BEFORE
|
||||
// the portable prefs were pulled from the database. Re-read it now, or a
|
||||
// copied data/ folder would show ISO until the next launch.
|
||||
reloadDateFormat()
|
||||
// Catch what a boundary cannot: errors thrown from timers, event handlers and
|
||||
// rejected promises. Installed before the first render so a fault during
|
||||
// startup is reported too.
|
||||
|
||||
@@ -679,10 +679,10 @@
|
||||
disappears on the dark themes. Invert it there — the control itself is worth
|
||||
keeping (OS calendar, locale date order, keyboard entry), only its icon needs
|
||||
help. */
|
||||
[data-theme='dim-slate'] input[type='date']::-webkit-calendar-picker-indicator,
|
||||
[data-theme='dark-warm'] input[type='date']::-webkit-calendar-picker-indicator,
|
||||
[data-theme='dark-graphite'] input[type='date']::-webkit-calendar-picker-indicator,
|
||||
[data-theme='high-contrast'] input[type='date']::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1) brightness(1.6);
|
||||
opacity: 0.75;
|
||||
Every temporal input carries the same glyph, so the rule names them all:
|
||||
datetime-local was left out at first and its icon stayed invisible. */
|
||||
:is([data-theme='dim-slate'], [data-theme='dark-warm'], [data-theme='dark-graphite'], [data-theme='high-contrast'])
|
||||
:is(input[type='date'], input[type='datetime-local'], input[type='time'], input[type='month'], input[type='week'])::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1) brightness(1.8);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@@ -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.2';
|
||||
export const APP_VERSION = '0.23.1';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+26
-2
@@ -61,6 +61,8 @@ export function ApplyAwardUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||
|
||||
export function AudioApplyLevels(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function AudioMonitorActive():Promise<boolean>;
|
||||
|
||||
export function AudioStartMonitor():Promise<void>;
|
||||
@@ -73,7 +75,7 @@ export function AudioStopTX():Promise<void>;
|
||||
|
||||
export function AudioTXActive():Promise<boolean>;
|
||||
|
||||
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
||||
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function AwardFields():Promise<Array<string>>;
|
||||
|
||||
@@ -91,6 +93,8 @@ export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Prom
|
||||
|
||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||
|
||||
export function CIVTraceEnabled():Promise<boolean>;
|
||||
|
||||
export function CWDecoderRunning():Promise<boolean>;
|
||||
|
||||
export function ChatAvailable():Promise<boolean>;
|
||||
@@ -215,6 +219,8 @@ export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexApplyBandPower(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function FlexBackspaceCW(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexMox(arg1:boolean):Promise<void>;
|
||||
@@ -359,7 +365,7 @@ export function GetAudioSettings():Promise<main.AudioSettings>;
|
||||
|
||||
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
||||
|
||||
export function GetAward(arg1:string):Promise<award.Result>;
|
||||
export function GetAward(arg1:string,arg2:string):Promise<award.Result>;
|
||||
|
||||
export function GetAwardDefs():Promise<Array<award.Def>>;
|
||||
|
||||
@@ -417,6 +423,8 @@ export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||
|
||||
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
|
||||
|
||||
export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
|
||||
|
||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||
|
||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||
@@ -597,6 +605,8 @@ export function ImportAwards():Promise<main.AwardImportResult>;
|
||||
|
||||
export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
||||
|
||||
export function IsNewUSCounty(arg1:string,arg2:string):Promise<boolean>;
|
||||
|
||||
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||
|
||||
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||
@@ -747,10 +757,20 @@ export function QSOAudioBegin():Promise<boolean>;
|
||||
|
||||
export function QSOAudioCancel():Promise<void>;
|
||||
|
||||
export function QSOAudioManualReady():Promise<boolean>;
|
||||
|
||||
export function QSOAudioManualStart():Promise<boolean>;
|
||||
|
||||
export function QSOAudioPlayOnAir():Promise<void>;
|
||||
|
||||
export function QSOAudioResetClock():Promise<boolean>;
|
||||
|
||||
export function QSOAudioRestart():Promise<boolean>;
|
||||
|
||||
export function QSOAudioResume():Promise<boolean>;
|
||||
|
||||
export function QSOAudioStop():Promise<boolean>;
|
||||
|
||||
export function QuitApp():Promise<void>;
|
||||
|
||||
export function RecomputeAllAwardRefs():Promise<number>;
|
||||
@@ -837,6 +857,8 @@ export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>
|
||||
|
||||
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
|
||||
|
||||
export function SaveFlexBandPower(arg1:Record<string, main.FlexBandPower>):Promise<void>;
|
||||
|
||||
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||
|
||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||
@@ -1037,6 +1059,8 @@ export function WinkeyerSetSpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function WinkeyerStop():Promise<void>;
|
||||
|
||||
export function WinkeyerTraceEnabled():Promise<boolean>;
|
||||
|
||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||
|
||||
export function YaesuSendCW(arg1:string):Promise<void>;
|
||||
|
||||
@@ -70,6 +70,10 @@ export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function AudioApplyLevels(arg1, arg2) {
|
||||
return window['go']['main']['App']['AudioApplyLevels'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function AudioMonitorActive() {
|
||||
return window['go']['main']['App']['AudioMonitorActive']();
|
||||
}
|
||||
@@ -94,8 +98,8 @@ export function AudioTXActive() {
|
||||
return window['go']['main']['App']['AudioTXActive']();
|
||||
}
|
||||
|
||||
export function AwardCellQSOs(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
||||
export function AwardCellQSOs(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function AwardFields() {
|
||||
@@ -130,6 +134,10 @@ export function BulkUpdateQSL(arg1, arg2) {
|
||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function CIVTraceEnabled() {
|
||||
return window['go']['main']['App']['CIVTraceEnabled']();
|
||||
}
|
||||
|
||||
export function CWDecoderRunning() {
|
||||
return window['go']['main']['App']['CWDecoderRunning']();
|
||||
}
|
||||
@@ -378,6 +386,10 @@ export function FlexApplyBandAntenna(arg1) {
|
||||
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
|
||||
}
|
||||
|
||||
export function FlexApplyBandPower(arg1, arg2) {
|
||||
return window['go']['main']['App']['FlexApplyBandPower'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function FlexBackspaceCW(arg1) {
|
||||
return window['go']['main']['App']['FlexBackspaceCW'](arg1);
|
||||
}
|
||||
@@ -666,8 +678,8 @@ export function GetAutostartPrograms() {
|
||||
return window['go']['main']['App']['GetAutostartPrograms']();
|
||||
}
|
||||
|
||||
export function GetAward(arg1) {
|
||||
return window['go']['main']['App']['GetAward'](arg1);
|
||||
export function GetAward(arg1, arg2) {
|
||||
return window['go']['main']['App']['GetAward'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetAwardDefs() {
|
||||
@@ -782,6 +794,10 @@ export function GetFlexBandAntennas() {
|
||||
return window['go']['main']['App']['GetFlexBandAntennas']();
|
||||
}
|
||||
|
||||
export function GetFlexBandPower() {
|
||||
return window['go']['main']['App']['GetFlexBandPower']();
|
||||
}
|
||||
|
||||
export function GetFlexState() {
|
||||
return window['go']['main']['App']['GetFlexState']();
|
||||
}
|
||||
@@ -1142,6 +1158,10 @@ export function InspectAwardImport() {
|
||||
return window['go']['main']['App']['InspectAwardImport']();
|
||||
}
|
||||
|
||||
export function IsNewUSCounty(arg1, arg2) {
|
||||
return window['go']['main']['App']['IsNewUSCounty'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function LaunchAutostartProgram(arg1) {
|
||||
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
||||
}
|
||||
@@ -1442,6 +1462,18 @@ export function QSOAudioCancel() {
|
||||
return window['go']['main']['App']['QSOAudioCancel']();
|
||||
}
|
||||
|
||||
export function QSOAudioManualReady() {
|
||||
return window['go']['main']['App']['QSOAudioManualReady']();
|
||||
}
|
||||
|
||||
export function QSOAudioManualStart() {
|
||||
return window['go']['main']['App']['QSOAudioManualStart']();
|
||||
}
|
||||
|
||||
export function QSOAudioPlayOnAir() {
|
||||
return window['go']['main']['App']['QSOAudioPlayOnAir']();
|
||||
}
|
||||
|
||||
export function QSOAudioResetClock() {
|
||||
return window['go']['main']['App']['QSOAudioResetClock']();
|
||||
}
|
||||
@@ -1450,6 +1482,14 @@ export function QSOAudioRestart() {
|
||||
return window['go']['main']['App']['QSOAudioRestart']();
|
||||
}
|
||||
|
||||
export function QSOAudioResume() {
|
||||
return window['go']['main']['App']['QSOAudioResume']();
|
||||
}
|
||||
|
||||
export function QSOAudioStop() {
|
||||
return window['go']['main']['App']['QSOAudioStop']();
|
||||
}
|
||||
|
||||
export function QuitApp() {
|
||||
return window['go']['main']['App']['QuitApp']();
|
||||
}
|
||||
@@ -1622,6 +1662,10 @@ export function SaveFlexBandAntennas(arg1) {
|
||||
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
|
||||
}
|
||||
|
||||
export function SaveFlexBandPower(arg1) {
|
||||
return window['go']['main']['App']['SaveFlexBandPower'](arg1);
|
||||
}
|
||||
|
||||
export function SaveListsSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
||||
}
|
||||
@@ -2022,6 +2066,10 @@ export function WinkeyerStop() {
|
||||
return window['go']['main']['App']['WinkeyerStop']();
|
||||
}
|
||||
|
||||
export function WinkeyerTraceEnabled() {
|
||||
return window['go']['main']['App']['WinkeyerTraceEnabled']();
|
||||
}
|
||||
|
||||
export function WorkedBefore(arg1, arg2) {
|
||||
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -316,6 +316,7 @@ export namespace award {
|
||||
leading_str?: string;
|
||||
trailing_str?: string;
|
||||
dynamic?: boolean;
|
||||
one_ref_per_qso?: boolean;
|
||||
or_rules?: OrRule[];
|
||||
dxcc_filter: number[];
|
||||
valid_bands?: string[];
|
||||
@@ -356,6 +357,7 @@ export namespace award {
|
||||
this.leading_str = source["leading_str"];
|
||||
this.trailing_str = source["trailing_str"];
|
||||
this.dynamic = source["dynamic"];
|
||||
this.one_ref_per_qso = source["one_ref_per_qso"];
|
||||
this.or_rules = this.convertValues(source["or_rules"], OrRule);
|
||||
this.dxcc_filter = source["dxcc_filter"];
|
||||
this.valid_bands = source["valid_bands"];
|
||||
@@ -461,6 +463,8 @@ export namespace award {
|
||||
ref_count: number;
|
||||
steps: Step[];
|
||||
manual?: string[];
|
||||
superseded?: string[];
|
||||
ambiguous?: string[];
|
||||
result: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -476,6 +480,8 @@ export namespace award {
|
||||
this.ref_count = source["ref_count"];
|
||||
this.steps = this.convertValues(source["steps"], Step);
|
||||
this.manual = source["manual"];
|
||||
this.superseded = source["superseded"];
|
||||
this.ambiguous = source["ambiguous"];
|
||||
this.result = source["result"];
|
||||
}
|
||||
|
||||
@@ -506,6 +512,8 @@ export namespace award {
|
||||
worked: boolean;
|
||||
confirmed: boolean;
|
||||
validated: boolean;
|
||||
modes: string[];
|
||||
confirmed_modes: string[];
|
||||
bands: string[];
|
||||
confirmed_bands: string[];
|
||||
validated_bands: string[];
|
||||
@@ -523,6 +531,8 @@ export namespace award {
|
||||
this.worked = source["worked"];
|
||||
this.confirmed = source["confirmed"];
|
||||
this.validated = source["validated"];
|
||||
this.modes = source["modes"];
|
||||
this.confirmed_modes = source["confirmed_modes"];
|
||||
this.bands = source["bands"];
|
||||
this.confirmed_bands = source["confirmed_bands"];
|
||||
this.validated_bands = source["validated_bands"];
|
||||
@@ -1297,6 +1307,7 @@ export namespace extsvc {
|
||||
hrdlog: ServiceConfig;
|
||||
eqsl: ServiceConfig;
|
||||
cloudlog: ServiceConfig;
|
||||
delete_remote: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ExternalServices(source);
|
||||
@@ -1310,6 +1321,7 @@ export namespace extsvc {
|
||||
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
|
||||
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
||||
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
|
||||
this.delete_remote = source["delete_remote"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -1602,6 +1614,7 @@ export namespace main {
|
||||
from_gain: number;
|
||||
mic_gain: number;
|
||||
tx_gain: number;
|
||||
qso_play_gain: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AudioSettings(source);
|
||||
@@ -1622,6 +1635,7 @@ export namespace main {
|
||||
this.from_gain = source["from_gain"];
|
||||
this.mic_gain = source["mic_gain"];
|
||||
this.tx_gain = source["tx_gain"];
|
||||
this.qso_play_gain = source["qso_play_gain"];
|
||||
}
|
||||
}
|
||||
export class AutostartLaunchResult {
|
||||
@@ -1904,10 +1918,14 @@ export namespace main {
|
||||
xiegu_port: string;
|
||||
xiegu_baud: number;
|
||||
xiegu_addr: number;
|
||||
xiegu_ptt_line: string;
|
||||
yaesu_port: string;
|
||||
yaesu_baud: number;
|
||||
kenwood_host: string;
|
||||
kenwood_port: string;
|
||||
kenwood_baud: number;
|
||||
yaesu_low_lines: boolean;
|
||||
kenwood_low_lines: boolean;
|
||||
icom_port: string;
|
||||
icom_baud: number;
|
||||
icom_addr: number;
|
||||
@@ -1942,10 +1960,14 @@ export namespace main {
|
||||
this.xiegu_port = source["xiegu_port"];
|
||||
this.xiegu_baud = source["xiegu_baud"];
|
||||
this.xiegu_addr = source["xiegu_addr"];
|
||||
this.xiegu_ptt_line = source["xiegu_ptt_line"];
|
||||
this.yaesu_port = source["yaesu_port"];
|
||||
this.yaesu_baud = source["yaesu_baud"];
|
||||
this.kenwood_host = source["kenwood_host"];
|
||||
this.kenwood_port = source["kenwood_port"];
|
||||
this.kenwood_baud = source["kenwood_baud"];
|
||||
this.yaesu_low_lines = source["yaesu_low_lines"];
|
||||
this.kenwood_low_lines = source["kenwood_low_lines"];
|
||||
this.icom_port = source["icom_port"];
|
||||
this.icom_baud = source["icom_baud"];
|
||||
this.icom_addr = source["icom_addr"];
|
||||
@@ -2690,6 +2712,7 @@ export namespace main {
|
||||
ref: string;
|
||||
name?: string;
|
||||
pickable: boolean;
|
||||
ambiguous?: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new QSOAwardRef(source);
|
||||
@@ -2701,6 +2724,7 @@ export namespace main {
|
||||
this.ref = source["ref"];
|
||||
this.name = source["name"];
|
||||
this.pickable = source["pickable"];
|
||||
this.ambiguous = source["ambiguous"];
|
||||
}
|
||||
}
|
||||
export class QSORate {
|
||||
@@ -2880,6 +2904,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);
|
||||
@@ -2896,6 +2922,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 {
|
||||
@@ -3477,6 +3505,11 @@ export namespace powergenius {
|
||||
fan_mode?: string;
|
||||
temperature: number;
|
||||
operate: boolean;
|
||||
fwd_w: number;
|
||||
peak_w: number;
|
||||
vswr: number;
|
||||
id: number;
|
||||
peak_id: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Status(source);
|
||||
@@ -3491,6 +3524,11 @@ export namespace powergenius {
|
||||
this.fan_mode = source["fan_mode"];
|
||||
this.temperature = source["temperature"];
|
||||
this.operate = source["operate"];
|
||||
this.fwd_w = source["fwd_w"];
|
||||
this.peak_w = source["peak_w"];
|
||||
this.vswr = source["vswr"];
|
||||
this.id = source["id"];
|
||||
this.peak_id = source["peak_id"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4536,6 +4574,7 @@ export namespace spe {
|
||||
|
||||
export class Status {
|
||||
connected: boolean;
|
||||
transport: string;
|
||||
last_error?: string;
|
||||
model?: string;
|
||||
operate: boolean;
|
||||
@@ -4559,6 +4598,7 @@ export namespace spe {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.connected = source["connected"];
|
||||
this.transport = source["transport"];
|
||||
this.last_error = source["last_error"];
|
||||
this.model = source["model"];
|
||||
this.operate = source["operate"];
|
||||
@@ -4613,6 +4653,7 @@ export namespace tunergenius {
|
||||
last_error?: string;
|
||||
fwd_dbm: number;
|
||||
fwd_w: number;
|
||||
peak_w: number;
|
||||
swr_db: number;
|
||||
vswr: number;
|
||||
operate: boolean;
|
||||
@@ -4640,6 +4681,7 @@ export namespace tunergenius {
|
||||
this.last_error = source["last_error"];
|
||||
this.fwd_dbm = source["fwd_dbm"];
|
||||
this.fwd_w = source["fwd_w"];
|
||||
this.peak_w = source["peak_w"];
|
||||
this.swr_db = source["swr_db"];
|
||||
this.vswr = source["vswr"];
|
||||
this.operate = source["operate"];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -101,3 +101,30 @@ func endpointName(dev *wca.IMMDevice, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// DeviceName resolves an endpoint id to its friendly name.
|
||||
//
|
||||
// Diagnostics quote the id that was CONFIGURED, which is a GUID — an operator
|
||||
// told "no audio at all from {0.0.1.00000000}.{6a27abfd…}" learns nothing they
|
||||
// can act on, while "no audio at all from DAX RX 1 (FlexRadio DAX)" points
|
||||
// straight at the DAX panel.
|
||||
//
|
||||
// Falls back to the id when the endpoint cannot be found, which is itself worth
|
||||
// seeing: a device that has disappeared explains an empty recording too.
|
||||
func DeviceName(id string) string {
|
||||
if id == "" {
|
||||
return "(none)"
|
||||
}
|
||||
for _, list := range []func() ([]Device, error){ListInputDevices, ListOutputDevices} {
|
||||
devs, err := list()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, d := range devs {
|
||||
if d.ID == id {
|
||||
return d.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -80,7 +80,17 @@ func pcmFormat() *wca.WAVEFORMATEX {
|
||||
}
|
||||
}
|
||||
|
||||
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | wca.AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY
|
||||
// autoConvert lets WASAPI resample between the device format and ours.
|
||||
//
|
||||
// SRC_DEFAULT_QUALITY used to be set alongside it, and that name is misleading:
|
||||
// it selects the CHEAP converter, meant for cases where quality does not matter.
|
||||
// Going from a rig 48 kHz stream down to our 16 kHz, it folds everything above
|
||||
// 8 kHz back into the audio band — and a receiver hiss is mostly high
|
||||
// frequencies. The result was a recording where the noise sat ON TOP of the
|
||||
// voice, while the same audio heard live had the voice well clear of it.
|
||||
//
|
||||
// Without the flag Windows uses its normal converter, which filters first.
|
||||
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
|
||||
|
||||
// recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the
|
||||
// stop channel is closed.
|
||||
|
||||
+89
-10
@@ -5,16 +5,28 @@ package audio
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Manager owns the DVK record/playback lifecycle: at most one recording and
|
||||
// one playback at a time. Device ids are passed per call so the host can route
|
||||
// recording to the mic and playback to the rig (or the preview speakers).
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
recStop chan struct{}
|
||||
recDone chan recResult
|
||||
playStop chan struct{}
|
||||
mu sync.Mutex
|
||||
recStop chan struct{}
|
||||
recDone chan recResult
|
||||
// monGainPct scales what the RX monitor plays, 100 = as captured.
|
||||
//
|
||||
// The "From radio" slider used to reach only the QSO recorder, so an
|
||||
// operator listening through OpsLog heard no difference between 10% and
|
||||
// 150% — the setting looked broken because it was, for the thing they were
|
||||
// listening to.
|
||||
monGainPct int
|
||||
playStop chan struct{}
|
||||
// playDone closes when the playback goroutine has returned and the audio
|
||||
// device is free again. Without it, the next Play raced the old one for the
|
||||
// device and lost.
|
||||
playDone chan struct{}
|
||||
monStop chan struct{} // RX monitor passthrough (capture → render)
|
||||
monRing *pcmRing // live audio hand-off, also fed by the network stream
|
||||
txStop chan struct{} // TX audio passthrough (mic → rig)
|
||||
@@ -125,18 +137,38 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error {
|
||||
pcm[i], pcm[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
|
||||
}
|
||||
}
|
||||
// Waits for any previous playback to have RELEASED THE DEVICE.
|
||||
//
|
||||
// It used to only signal the old one to stop and start a new one at once.
|
||||
// The old goroutine still held the WASAPI render client for a moment, so the
|
||||
// new client could not start: it returned immediately, the PTT was keyed and
|
||||
// released a tenth of a second later, and nothing came out. On the air that
|
||||
// looked like "press play again and you must wait the whole length of the
|
||||
// message before it will play at all".
|
||||
m.StopPlayback()
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
m.mu.Lock()
|
||||
m.playStop = stop
|
||||
m.playDone = done
|
||||
m.mu.Unlock()
|
||||
go func() {
|
||||
_ = playPCM(deviceID, pcm, rate, ch, bits, stop)
|
||||
// The error was discarded. A device that refuses to start returns here
|
||||
// instantly, the PTT is released 120 ms later, and NOTHING says why —
|
||||
// which is exactly what a station heard as "it plays once, then never
|
||||
// again": the call succeeded, the sound did not.
|
||||
if err := playPCM(deviceID, pcm, rate, ch, bits, stop); err != nil {
|
||||
LogSink("audio: playback on %q failed: %v", DeviceName(deviceID), err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.playStop == stop {
|
||||
m.playStop = nil
|
||||
}
|
||||
if m.playDone == done {
|
||||
m.playDone = nil
|
||||
}
|
||||
m.mu.Unlock()
|
||||
close(done) // the device is free from here
|
||||
m.notify()
|
||||
}()
|
||||
m.notify()
|
||||
@@ -147,12 +179,24 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error {
|
||||
func (m *Manager) StopPlayback() {
|
||||
m.mu.Lock()
|
||||
stop := m.playStop
|
||||
done := m.playDone
|
||||
m.playStop = nil
|
||||
m.mu.Unlock()
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
m.notify()
|
||||
if stop == nil {
|
||||
return
|
||||
}
|
||||
close(stop)
|
||||
// Wait for the goroutine to release the device — that is the whole point of
|
||||
// stopping before starting again. Bounded: a wedged WASAPI call must not
|
||||
// freeze the caller, which here is the operator clicking a button.
|
||||
if done != nil {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
LogSink("audio: previous playback did not release the device within 1.5 s")
|
||||
}
|
||||
}
|
||||
m.notify()
|
||||
}
|
||||
|
||||
// ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------
|
||||
@@ -188,9 +232,12 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
||||
m.mu.Unlock()
|
||||
|
||||
if capture {
|
||||
// Producer: capture the rig's USB audio into the ring.
|
||||
// Producer: capture the rig's USB audio into the ring, at the level the
|
||||
// operator set. Applied HERE rather than on the render side so the
|
||||
// network-fed path (PushMonitorAudio) keeps its own untouched levels —
|
||||
// that stream is already scaled by the radio.
|
||||
go func() {
|
||||
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) })
|
||||
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(m.scaleMonitor(chunk)) })
|
||||
}()
|
||||
}
|
||||
// Consumer: render the ring to the output device at the internal 16 kHz mono.
|
||||
@@ -201,6 +248,38 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMonitorGain sets the RX monitor level in percent (100 = as captured).
|
||||
// Takes effect on the next captured chunk — no need to restart the monitor.
|
||||
func (m *Manager) SetMonitorGain(pct int) {
|
||||
if pct <= 0 {
|
||||
pct = 100
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.monGainPct = pct
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// scaleMonitor applies the monitor level to one captured chunk, returning a
|
||||
// buffer the ring may keep. At unity it hands the chunk straight back: the
|
||||
// common case must not pay for a copy 30 times a second.
|
||||
func (m *Manager) scaleMonitor(chunk []byte) []byte {
|
||||
m.mu.Lock()
|
||||
pct := m.monGainPct
|
||||
m.mu.Unlock()
|
||||
if pct == 0 || pct == 100 {
|
||||
return chunk
|
||||
}
|
||||
g := float64(pct) / 100
|
||||
out := make([]byte, len(chunk))
|
||||
copy(out, chunk)
|
||||
for i := 0; i+1 < len(out); i += 2 {
|
||||
v := int16(uint16(out[i]) | uint16(out[i+1])<<8)
|
||||
v = scalePCM(v, g)
|
||||
out[i], out[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StopMonitor stops the RX monitor passthrough.
|
||||
func (m *Manager) StopMonitor() {
|
||||
m.mu.Lock()
|
||||
|
||||
+70
-7
@@ -3,6 +3,7 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
|
||||
"github.com/braheezy/shine-mp3/pkg/mp3"
|
||||
@@ -50,19 +51,81 @@ func writeMP3(path string, pcm []byte) error {
|
||||
return enc.Write(f, stereo)
|
||||
}
|
||||
|
||||
// upsample2 doubles the sample rate with linear interpolation (16 kHz → 32 kHz).
|
||||
// upsample2 doubles the sample rate, 16 kHz → 32 kHz, WITH the interpolation
|
||||
// filter that makes the operation legitimate.
|
||||
//
|
||||
// It used to interpolate linearly — each new sample the average of its
|
||||
// neighbours. That is an upsampler in name only: every component at f is
|
||||
// mirrored to 16 kHz − f, measured just 7 dB down (see the test). On speech it
|
||||
// adds brightness; on receiver noise, which is broadband, it lays a second
|
||||
// noise floor across the top of the band. Operators heard it as a hiss louder
|
||||
// than the voice, present in the MP3 and absent from the WAV of the very same
|
||||
// audio.
|
||||
//
|
||||
// Zero-stuffing followed by a windowed-sinc low-pass at the old Nyquist is the
|
||||
// textbook answer, and it puts the image below 40 dB. 63 taps is a few hundred
|
||||
// microseconds of work on an eight-second recording — nothing, next to the MP3
|
||||
// encode that follows.
|
||||
func upsample2(in []int16) []int16 {
|
||||
if len(in) == 0 {
|
||||
return in
|
||||
}
|
||||
out := make([]int16, len(in)*2)
|
||||
for i := range in {
|
||||
out[2*i] = in[i]
|
||||
if i+1 < len(in) {
|
||||
out[2*i+1] = int16((int32(in[i]) + int32(in[i+1])) / 2)
|
||||
} else {
|
||||
out[2*i+1] = in[i]
|
||||
half := len(upsampleFIR) / 2
|
||||
for i := range out {
|
||||
var acc float64
|
||||
// The zero-stuffed signal is non-zero only at even indices, so only
|
||||
// every other tap contributes — the loop skips the zeros rather than
|
||||
// multiplying by them.
|
||||
start := (i - half + 1) &^ 1 // first even index in the window
|
||||
for j := start; j <= i+half; j += 2 {
|
||||
k := i - j + half
|
||||
if k < 0 || k >= len(upsampleFIR) {
|
||||
continue
|
||||
}
|
||||
src := j / 2
|
||||
if src < 0 || src >= len(in) {
|
||||
continue
|
||||
}
|
||||
acc += float64(in[src]) * upsampleFIR[k]
|
||||
}
|
||||
// ×2 for the energy lost to zero-stuffing, then clamp.
|
||||
v := acc * 2
|
||||
if v > 32767 {
|
||||
v = 32767
|
||||
} else if v < -32768 {
|
||||
v = -32768
|
||||
}
|
||||
out[i] = int16(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// upsampleFIR is a 63-tap Hamming-windowed sinc, cut off at a quarter of the
|
||||
// NEW rate (8 kHz at 32 kHz) — exactly the old Nyquist, which is where the
|
||||
// images begin.
|
||||
var upsampleFIR = func() []float64 {
|
||||
const n = 63
|
||||
const fc = 0.25 // cycles/sample at the new rate
|
||||
h := make([]float64, n)
|
||||
mid := (n - 1) / 2
|
||||
var sum float64
|
||||
for i := 0; i < n; i++ {
|
||||
m := float64(i - mid)
|
||||
var v float64
|
||||
if m == 0 {
|
||||
v = 2 * fc
|
||||
} else {
|
||||
v = math.Sin(2*math.Pi*fc*m) / (math.Pi * m)
|
||||
}
|
||||
// Hamming window: a rectangular one would ring and put the stopband
|
||||
// only ~21 dB down, which is not enough to be worth the filter.
|
||||
v *= 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(n-1))
|
||||
h[i] = v
|
||||
sum += v
|
||||
}
|
||||
for i := range h {
|
||||
h[i] /= sum // unity gain at DC
|
||||
}
|
||||
return h
|
||||
}()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Playing a stopped take must not consume it.
|
||||
//
|
||||
// Reported on the air: the recording plays once, then nothing — "as if it threw
|
||||
// the sound away after playing it". PeekQSO is what the playback reads, and it
|
||||
// is meant to COPY; TakeQSO is the one that clears.
|
||||
func TestPeekDoesNotConsumeTheTake(t *testing.T) {
|
||||
r := &Recorder{running: true, active: true, paused: true, prerollSamples: sampleRate}
|
||||
r.acc = make([]int16, 4000)
|
||||
for i := range r.acc {
|
||||
r.acc[i] = int16(i % 500)
|
||||
}
|
||||
|
||||
first, err := r.PeekQSO()
|
||||
if err != nil {
|
||||
t.Fatalf("first peek: %v", err)
|
||||
}
|
||||
second, err := r.PeekQSO()
|
||||
if err != nil {
|
||||
t.Fatalf("second peek: %v — the take was consumed by the first", err)
|
||||
}
|
||||
if len(first) != len(second) {
|
||||
t.Errorf("second peek returned %d bytes, first %d", len(second), len(first))
|
||||
}
|
||||
|
||||
// And the copy must be independent: the caller holds these bytes while the
|
||||
// recorder may still be appending to its own slice.
|
||||
if len(first) > 0 {
|
||||
first[0] = ^first[0]
|
||||
again, _ := r.PeekQSO()
|
||||
if len(again) > 0 && again[0] == first[0] {
|
||||
t.Error("PeekQSO handed out its internal buffer, not a copy")
|
||||
}
|
||||
}
|
||||
|
||||
// Resuming and stopping again keeps everything captured so far.
|
||||
r.ResumeQSO()
|
||||
r.mu.Lock()
|
||||
r.acc = append(r.acc, make([]int16, 1000)...)
|
||||
r.mu.Unlock()
|
||||
r.PauseQSO()
|
||||
third, err := r.PeekQSO()
|
||||
if err != nil {
|
||||
t.Fatalf("peek after resume: %v", err)
|
||||
}
|
||||
if len(third) <= len(second) {
|
||||
t.Errorf("after resuming, the take is %d bytes — it should have grown past %d", len(third), len(second))
|
||||
}
|
||||
_ = time.Now
|
||||
}
|
||||
+254
-23
@@ -15,6 +15,14 @@ import (
|
||||
// Defaults to a no-op so the package is usable without wiring.
|
||||
var LogSink = func(string, ...any) {}
|
||||
|
||||
// AlertSink receives the few audio problems an operator must see WHILE they are
|
||||
// operating, not afterwards in a log file — a capture device that opens but
|
||||
// never streams being the one that matters: the recording is silently empty and
|
||||
// nothing says so until the QSO is logged and gone.
|
||||
//
|
||||
// Set to a toast emitter at startup; a no-op keeps the package standalone.
|
||||
var AlertSink = func(string, ...any) {}
|
||||
|
||||
// recoverGoroutine turns a panic in a long-running audio goroutine into a logged
|
||||
// event with a stack trace instead of a silent process-killing crash. (It can't
|
||||
// catch a hard Windows access violation from the WASAPI layer — those are fatal
|
||||
@@ -42,16 +50,31 @@ type Recorder struct {
|
||||
prerollSamples int
|
||||
|
||||
// Per-source sample queues (guarded by srcMu), drained by the mixer.
|
||||
srcMu sync.Mutex
|
||||
bufA []int16 // From Radio
|
||||
bufB []int16 // mic
|
||||
twoSrc bool
|
||||
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
|
||||
gainB float64 // mic gain
|
||||
srcMu sync.Mutex
|
||||
bufA []int16 // From Radio
|
||||
bufB []int16 // mic
|
||||
// When each source last delivered samples. A configured device that never
|
||||
// produces anything is not an error anywhere — the capture call just sits
|
||||
// there — so the only way to notice is to watch the clock.
|
||||
lastA, lastB time.Time
|
||||
// startedAt is when capture began — the reference for a source that has not
|
||||
// delivered anything at all yet.
|
||||
startedAt time.Time
|
||||
// deadB (deadA) latches once a source has been declared silent, so the
|
||||
// warning is logged once rather than 25 times a second.
|
||||
deadA, deadB bool
|
||||
twoSrc bool
|
||||
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
|
||||
gainB float64 // mic gain
|
||||
|
||||
// Mixed output state (guarded by mu).
|
||||
ring []int16 // last prerollSamples of mixed audio
|
||||
active bool
|
||||
// paused freezes an ACTIVE take: nothing more is accumulated, but everything
|
||||
// captured so far is kept and the take still ends normally when the QSO is
|
||||
// logged. It is what lets an operator stop, play the recording back on the
|
||||
// air to the station they are working, and still have it saved with the QSO.
|
||||
paused bool
|
||||
acc []int16 // active QSO accumulation (seeded from ring on BeginQSO)
|
||||
}
|
||||
|
||||
@@ -110,11 +133,37 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
if prerollSec < 0 {
|
||||
prerollSec = 0
|
||||
}
|
||||
// Does the configured endpoint still EXIST?
|
||||
//
|
||||
// Endpoint ids are stored, and a DAX channel that is reconfigured, disabled
|
||||
// or removed comes back with a different id. The old one then opens without
|
||||
// complaint on some drivers and simply never streams — which is
|
||||
// indistinguishable from a quiet band until the recording turns out empty.
|
||||
// Checking the list takes milliseconds and answers it outright.
|
||||
if devs, derr := ListInputDevices(); derr == nil {
|
||||
known := func(id string) bool {
|
||||
for _, d := range devs {
|
||||
if d.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if fromDev != "" && !known(fromDev) {
|
||||
LogSink("recorder: the configured radio input no longer exists (%s) — re-select it in Settings → Audio", fromDev)
|
||||
AlertSink("The configured radio audio input no longer exists — re-select it in Settings")
|
||||
}
|
||||
if micDev != "" && micDev != fromDev && !known(micDev) {
|
||||
LogSink("recorder: the configured microphone no longer exists (%s) — re-select it in Settings → Audio", micDev)
|
||||
}
|
||||
}
|
||||
r.prerollSamples = prerollSec * sampleRate
|
||||
r.twoSrc = micDev != "" && micDev != fromDev
|
||||
r.stopCh = make(chan struct{})
|
||||
r.running = true
|
||||
r.ring, r.acc, r.active, r.bufA, r.bufB = nil, nil, false, nil, nil
|
||||
r.startedAt = time.Now()
|
||||
r.ring, r.acc, r.active, r.paused, r.bufA, r.bufB = nil, nil, false, false, nil, nil
|
||||
r.lastA, r.lastB, r.deadA, r.deadB = time.Time{}, time.Time{}, false, false
|
||||
stop := r.stopCh
|
||||
twoSrc := r.twoSrc
|
||||
r.mu.Unlock()
|
||||
@@ -124,27 +173,79 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer recoverGoroutine("recorder capture (radio)")
|
||||
_ = captureStream(fromDev, stop, func(chunk []byte) {
|
||||
// The error was discarded here. A device that cannot be opened — renamed,
|
||||
// unplugged, held by another program — then looked exactly like a device
|
||||
// that is merely quiet, and the recording came out empty with nothing in
|
||||
// the log to say why.
|
||||
if err := captureStream(fromDev, stop, func(chunk []byte) {
|
||||
s := bytesToInt16(chunk)
|
||||
r.srcMu.Lock()
|
||||
r.bufA = append(r.bufA, s...)
|
||||
r.lastA = time.Now()
|
||||
r.srcMu.Unlock()
|
||||
})
|
||||
}); err != nil {
|
||||
LogSink("recorder: capture from %q failed: %v", DeviceName(fromDev), err)
|
||||
}
|
||||
}()
|
||||
if twoSrc {
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer recoverGoroutine("recorder capture (mic)")
|
||||
_ = captureStream(micDev, stop, func(chunk []byte) {
|
||||
if err := captureStream(micDev, stop, func(chunk []byte) {
|
||||
s := bytesToInt16(chunk)
|
||||
r.srcMu.Lock()
|
||||
r.bufB = append(r.bufB, s...)
|
||||
r.lastB = time.Now()
|
||||
r.srcMu.Unlock()
|
||||
})
|
||||
}); err != nil {
|
||||
LogSink("recorder: capture from %q failed: %v", DeviceName(micDev), err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Name the devices being recorded FROM, once, at the start.
|
||||
//
|
||||
// A station with two radios has two sets of endpoints, and the recorder will
|
||||
// happily capture the one that is not being listened to: the result is a
|
||||
// file full of hiss with no relation to what the operator hears, and nothing
|
||||
// anywhere said which receiver it came from.
|
||||
if twoSrc {
|
||||
LogSink("recorder: capturing %q + %q", DeviceName(fromDev), DeviceName(micDev))
|
||||
} else {
|
||||
LogSink("recorder: capturing %q", DeviceName(fromDev))
|
||||
}
|
||||
|
||||
// Watchdog. A WASAPI device can open cleanly and then produce nothing at
|
||||
// all — a DAX channel with no stream behind it does exactly that, and it is
|
||||
// indistinguishable from silence until the recording turns out to be empty
|
||||
// at the end of a QSO. Say it once, three seconds in, while there is still
|
||||
// time to fix the setup.
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer recoverGoroutine("recorder watchdog")
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
r.srcMu.Lock()
|
||||
aQuiet, bQuiet := r.lastA.IsZero(), r.lastB.IsZero()
|
||||
r.srcMu.Unlock()
|
||||
if aQuiet {
|
||||
LogSink("recorder: no audio at all from %q after 3 s — the device opened but nothing is streaming", DeviceName(fromDev))
|
||||
AlertSink("No audio from %s — nothing is being recorded", DeviceName(fromDev))
|
||||
}
|
||||
// The mic is only worth a warning when the RADIO is silent too. On CW the
|
||||
// mic channel legitimately delivers nothing, and the mixer has already
|
||||
// said "recording the radio alone" — repeating it as an alarm made the log
|
||||
// read as if something were broken while the recording was going fine.
|
||||
if twoSrc && bQuiet && aQuiet {
|
||||
LogSink("recorder: no audio at all from %q either", DeviceName(micDev))
|
||||
}
|
||||
}()
|
||||
|
||||
// Mixer goroutine.
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
@@ -166,15 +267,79 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
|
||||
// mixTick drains the source queues, mixes what's available, and appends to the
|
||||
// ring + active accumulation.
|
||||
// deadSourceAfter is how long a source may deliver nothing before the recorder
|
||||
// carries on without it. Long enough not to trip on a scheduling hiccup, short
|
||||
// enough that almost nothing is lost from the source that IS working.
|
||||
const deadSourceAfter = 1500 * time.Millisecond
|
||||
|
||||
func (r *Recorder) mixTick() {
|
||||
r.srcMu.Lock()
|
||||
var mixed []int16
|
||||
if r.twoSrc {
|
||||
// A source that has been silent for a while is treated as ABSENT and the
|
||||
// other one is recorded alone.
|
||||
//
|
||||
// Two sources used to mean min(len(A), len(B)) samples: if one device
|
||||
// delivered nothing, NOTHING was recorded, and the drift guard below
|
||||
// then threw the live source away a second at a time. That is exactly
|
||||
// what happened on a Flex in CW — DAX Mic delivers nothing when the mic
|
||||
// path is not running — and the operator got "recording was empty" after
|
||||
// a whole QSO. Half a recording is worth having; silence is not.
|
||||
now := time.Now()
|
||||
// A source is dry when it has been quiet for too long — and a source that
|
||||
// has NEVER delivered is measured from when capture started, because it
|
||||
// has no last-delivery time of its own.
|
||||
//
|
||||
// The first version required a source to have spoken at least once. That
|
||||
// covers a device that stops, but not the one that actually happens: a
|
||||
// DAX Mic channel that is simply switched off never delivers a single
|
||||
// sample, so it stayed "not yet dry" forever and took the whole recording
|
||||
// down with it. Three empty CW recordings, with the radio audio streaming
|
||||
// perfectly the entire time.
|
||||
dry := func(last time.Time) bool {
|
||||
if last.IsZero() {
|
||||
return now.Sub(r.startedAt) > deadSourceAfter
|
||||
}
|
||||
return now.Sub(last) > deadSourceAfter
|
||||
}
|
||||
aDry, bDry := dry(r.lastA), dry(r.lastB)
|
||||
if bDry && !aDry && len(r.bufA) > 0 {
|
||||
if !r.deadB {
|
||||
r.deadB = true
|
||||
LogSink("recorder: the second audio source is silent — recording the radio alone")
|
||||
}
|
||||
mixed = make([]int16, len(r.bufA))
|
||||
for i, v := range r.bufA {
|
||||
mixed[i] = scaleSample(v, r.gainA)
|
||||
}
|
||||
r.bufA = r.bufA[:0]
|
||||
r.bufB = r.bufB[:0]
|
||||
r.srcMu.Unlock()
|
||||
r.store(mixed)
|
||||
return
|
||||
}
|
||||
if aDry && !bDry && len(r.bufB) > 0 {
|
||||
if !r.deadA {
|
||||
r.deadA = true
|
||||
LogSink("recorder: the radio audio source is silent — recording the microphone alone")
|
||||
}
|
||||
mixed = make([]int16, len(r.bufB))
|
||||
for i, v := range r.bufB {
|
||||
mixed[i] = scaleSample(v, r.gainB)
|
||||
}
|
||||
r.bufA = r.bufA[:0]
|
||||
r.bufB = r.bufB[:0]
|
||||
r.srcMu.Unlock()
|
||||
r.store(mixed)
|
||||
return
|
||||
}
|
||||
n := len(r.bufA)
|
||||
if len(r.bufB) < n {
|
||||
n = len(r.bufB)
|
||||
}
|
||||
if n > 0 {
|
||||
// Both alive again after one was written off.
|
||||
r.deadA, r.deadB = false, false
|
||||
mixed = make([]int16, n)
|
||||
for i := 0; i < n; i++ {
|
||||
mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB))
|
||||
@@ -182,12 +347,20 @@ func (r *Recorder) mixTick() {
|
||||
r.bufA = append(r.bufA[:0], r.bufA[n:]...)
|
||||
r.bufB = append(r.bufB[:0], r.bufB[n:]...)
|
||||
}
|
||||
// Drift guard: if the clocks diverge, drop the excess so the two
|
||||
// sources stay roughly aligned (≤1 s skew).
|
||||
if d := len(r.bufA) - len(r.bufB); d > sampleRate {
|
||||
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
|
||||
} else if d < -sampleRate {
|
||||
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
|
||||
// Drift guard: two sound cards run on their own clocks, so drop the
|
||||
// excess to keep them within a second of each other.
|
||||
//
|
||||
// ONLY while both are actually running. A starved source is not drift: it
|
||||
// made this guard throw away the radio audio a second at a time during
|
||||
// the grace period, so the opening of every recording was lost even
|
||||
// though it had been captured. Waiting costs nothing now — whatever is
|
||||
// buffered is written whole the moment the silent source is written off.
|
||||
if len(r.bufA) > 0 && len(r.bufB) > 0 {
|
||||
if d := len(r.bufA) - len(r.bufB); d > sampleRate {
|
||||
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
|
||||
} else if d < -sampleRate {
|
||||
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
|
||||
}
|
||||
}
|
||||
} else if len(r.bufA) > 0 {
|
||||
mixed = make([]int16, len(r.bufA))
|
||||
@@ -198,6 +371,12 @@ func (r *Recorder) mixTick() {
|
||||
}
|
||||
r.srcMu.Unlock()
|
||||
|
||||
r.store(mixed)
|
||||
}
|
||||
|
||||
// store appends mixed samples to the pre-roll ring and, when a take is running,
|
||||
// to the take itself.
|
||||
func (r *Recorder) store(mixed []int16) {
|
||||
if len(mixed) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -206,7 +385,7 @@ func (r *Recorder) mixTick() {
|
||||
if len(r.ring) > r.prerollSamples {
|
||||
r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
|
||||
}
|
||||
if r.active {
|
||||
if r.active && !r.paused {
|
||||
r.acc = append(r.acc, mixed...)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
@@ -221,7 +400,7 @@ func (r *Recorder) BeginQSO() {
|
||||
return
|
||||
}
|
||||
r.acc = append([]int16(nil), r.ring...)
|
||||
r.active = true
|
||||
r.active, r.paused = true, false
|
||||
}
|
||||
|
||||
// RestartQSO begins a fresh accumulation even if one is already active —
|
||||
@@ -236,7 +415,7 @@ func (r *Recorder) RestartQSO() {
|
||||
return
|
||||
}
|
||||
r.acc = append([]int16(nil), r.ring...)
|
||||
r.active = true
|
||||
r.active, r.paused = true, false
|
||||
}
|
||||
|
||||
// ResetQSOClock restarts the active accumulation from ZERO — discarding
|
||||
@@ -266,7 +445,7 @@ func (r *Recorder) TakeQSO() ([]byte, error) {
|
||||
return nil, fmt.Errorf("no active recording")
|
||||
}
|
||||
samples := r.acc
|
||||
r.acc, r.active = nil, false
|
||||
r.acc, r.active, r.paused = nil, false, false
|
||||
r.mu.Unlock()
|
||||
if len(samples) == 0 {
|
||||
return nil, fmt.Errorf("recording was empty")
|
||||
@@ -295,7 +474,7 @@ func (r *Recorder) SaveQSO(path string) error {
|
||||
// DiscardQSO drops the active accumulation without saving (callsign cleared).
|
||||
func (r *Recorder) DiscardQSO() {
|
||||
r.mu.Lock()
|
||||
r.acc, r.active = nil, false
|
||||
r.acc, r.active, r.paused = nil, false, false
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -313,7 +492,7 @@ func (r *Recorder) Stop() {
|
||||
close(stop)
|
||||
r.wg.Wait()
|
||||
r.mu.Lock()
|
||||
r.ring, r.acc, r.active = nil, nil, false
|
||||
r.ring, r.acc, r.active, r.paused = nil, nil, false, false
|
||||
r.mu.Unlock()
|
||||
r.srcMu.Lock()
|
||||
r.bufA, r.bufB = nil, nil
|
||||
@@ -346,3 +525,55 @@ func int16sToBytes(s []int16) []byte {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// PauseQSO freezes the active take without ending it: nothing more is recorded,
|
||||
// nothing is thrown away, and logging the QSO still writes the file.
|
||||
//
|
||||
// This exists for one situation, which is common enough on the bands to be
|
||||
// worth the state: you are working a station, you have been recording, and they
|
||||
// ask to hear it. You stop, you play it back to them on the air, and the
|
||||
// recording is still saved with the QSO afterwards.
|
||||
func (r *Recorder) PauseQSO() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.active {
|
||||
return false
|
||||
}
|
||||
r.paused = true
|
||||
return true
|
||||
}
|
||||
|
||||
// ResumeQSO continues an interrupted take, appending to what is already there.
|
||||
func (r *Recorder) ResumeQSO() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.active {
|
||||
return false
|
||||
}
|
||||
r.paused = false
|
||||
return true
|
||||
}
|
||||
|
||||
// Paused reports whether the active take is frozen.
|
||||
func (r *Recorder) Paused() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.active && r.paused
|
||||
}
|
||||
|
||||
// PeekQSO returns what has been captured so far WITHOUT ending the take.
|
||||
//
|
||||
// Unlike TakeQSO this keeps the audio, because the take is going to be played
|
||||
// back and then still saved with the QSO. The copy is deliberate: the caller
|
||||
// gets bytes it can hold while the recorder keeps appending to its own slice.
|
||||
func (r *Recorder) PeekQSO() ([]byte, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.active {
|
||||
return nil, fmt.Errorf("no active recording")
|
||||
}
|
||||
if len(r.acc) == 0 {
|
||||
return nil, fmt.Errorf("recording is empty")
|
||||
}
|
||||
return int16sToBytes(append([]int16(nil), r.acc...)), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A silent second source must not silence the recording.
|
||||
//
|
||||
// From a real session: a Flex with "From Radio" on DAX RX and "Recording mic"
|
||||
// on DAX Mic. In CW the mic path does not run, so DAX Mic delivered nothing —
|
||||
// and because the mixer took min(len(A), len(B)) samples, nothing at all was
|
||||
// recorded. The whole QSO ended in "recording was empty".
|
||||
func TestMixerSurvivesASilentSource(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
|
||||
// The radio has been talking; the mic has not spoken for well over the
|
||||
// grace period.
|
||||
r.bufA = make([]int16, 800)
|
||||
for i := range r.bufA {
|
||||
r.bufA[i] = 1000
|
||||
}
|
||||
r.lastA = time.Now()
|
||||
r.lastB = time.Now().Add(-5 * time.Second)
|
||||
|
||||
r.mixTick()
|
||||
|
||||
if len(r.acc) == 0 {
|
||||
t.Fatal("nothing was recorded — a silent mic must not stop the radio being captured")
|
||||
}
|
||||
if r.acc[0] != 1000 {
|
||||
t.Errorf("sample = %d, want 1000 — the live source must pass through unchanged", r.acc[0])
|
||||
}
|
||||
}
|
||||
|
||||
// While BOTH sources are alive the two are mixed, as before.
|
||||
func TestMixerStillMixesBothSources(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
r.bufA = []int16{100, 100, 100}
|
||||
r.bufB = []int16{50, 50, 50}
|
||||
now := time.Now()
|
||||
r.lastA, r.lastB = now, now
|
||||
|
||||
r.mixTick()
|
||||
|
||||
if len(r.acc) != 3 {
|
||||
t.Fatalf("recorded %d samples, want 3", len(r.acc))
|
||||
}
|
||||
if r.acc[0] != 150 {
|
||||
t.Errorf("sample = %d, want 150 — both sources should be summed", r.acc[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Just after capture starts, a source that has not delivered yet is NOT written
|
||||
// off: the first samples take a moment to arrive, and declaring the mic dead at
|
||||
// once would drop the opening of every recording.
|
||||
func TestMixerWaitsBrieflyAtStartup(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
r.startedAt = time.Now()
|
||||
r.bufA = []int16{100, 100}
|
||||
|
||||
r.mixTick()
|
||||
|
||||
if len(r.acc) != 0 {
|
||||
t.Errorf("recorded %d samples immediately — the second source deserves a moment to start", len(r.acc))
|
||||
}
|
||||
}
|
||||
|
||||
// A source that has NEVER delivered is written off once enough time has passed.
|
||||
//
|
||||
// This is the case that actually happens, and the one the first version missed:
|
||||
// a DAX Mic channel switched off delivers not one sample, so it had no
|
||||
// last-delivery time and stayed forever "not yet dry" — taking the whole
|
||||
// recording down with it while the radio audio streamed perfectly.
|
||||
func TestMixerWritesOffASourceThatNeverSpoke(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
r.startedAt = time.Now().Add(-5 * time.Second)
|
||||
r.bufA = []int16{700, 700, 700}
|
||||
r.lastA = time.Now()
|
||||
// lastB stays zero: the mic has never produced anything at all.
|
||||
|
||||
r.mixTick()
|
||||
|
||||
if len(r.acc) != 3 {
|
||||
t.Fatalf("recorded %d samples, want 3 — the radio was streaming the whole time", len(r.acc))
|
||||
}
|
||||
if r.acc[0] != 700 {
|
||||
t.Errorf("sample = %d, want 700", r.acc[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing captured during the grace period is thrown away.
|
||||
//
|
||||
// The drift guard exists for two sound cards running on their own clocks. A
|
||||
// STARVED source is not drift, and treating it as such discarded the radio
|
||||
// audio a second at a time while the mixer was still waiting for the mic — so
|
||||
// the opening of every CW recording was lost although it had been captured.
|
||||
func TestGracePeriodKeepsWhatWasCaptured(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
r.startedAt = time.Now()
|
||||
|
||||
// Three seconds of radio audio arrive while the mic says nothing at all.
|
||||
r.bufA = make([]int16, 3*sampleRate)
|
||||
for i := range r.bufA {
|
||||
r.bufA[i] = 500
|
||||
}
|
||||
r.lastA = time.Now()
|
||||
r.mixTick() // still inside the grace period: nothing is written yet…
|
||||
|
||||
if len(r.acc) != 0 {
|
||||
t.Fatalf("wrote %d samples before the mic was written off", len(r.acc))
|
||||
}
|
||||
if len(r.bufA) != 3*sampleRate {
|
||||
t.Fatalf("buffered audio was trimmed to %d samples — it must be kept, not dropped", len(r.bufA))
|
||||
}
|
||||
|
||||
// …and once the mic is written off, everything captured comes through.
|
||||
r.startedAt = time.Now().Add(-5 * time.Second)
|
||||
r.mixTick()
|
||||
if len(r.acc) != 3*sampleRate {
|
||||
t.Errorf("recorded %d samples, want the full %d captured during the wait", len(r.acc), 3*sampleRate)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// goertzel returns the magnitude of one frequency bin, so a test can ask "how
|
||||
// much energy is at 10 kHz" without pulling in an FFT.
|
||||
func goertzel(x []int16, fs, freq float64) float64 {
|
||||
w := 2 * math.Pi * freq / fs
|
||||
c := 2 * math.Cos(w)
|
||||
var s1, s2 float64
|
||||
for _, v := range x {
|
||||
s := float64(v) + c*s1 - s2
|
||||
s2, s1 = s1, s
|
||||
}
|
||||
return math.Hypot(s1-s2*math.Cos(w), s2*math.Sin(w))
|
||||
}
|
||||
|
||||
// Upsampling 16 kHz → 32 kHz must not create an audible mirror image.
|
||||
//
|
||||
// MP3 recordings came back with a hiss that is not on the air and is not in the
|
||||
// WAV of the same audio — "louder than the voice", from a station that hears the
|
||||
// voice well clear of the noise. The MP3 path is the only one that resamples,
|
||||
// and it did so by linear interpolation: every component at f is mirrored to
|
||||
// 16 kHz − f, which for broadband receiver noise means a second noise floor
|
||||
// spread across the top of the band.
|
||||
func TestUpsampleSuppressesTheImage(t *testing.T) {
|
||||
const fs = 16000.0
|
||||
const tone = 6000.0 // its image lands at 16000 − 6000 = 10 kHz
|
||||
|
||||
in := make([]int16, 4096)
|
||||
for i := range in {
|
||||
in[i] = int16(12000 * math.Sin(2*math.Pi*tone*float64(i)/fs))
|
||||
}
|
||||
|
||||
out := upsample2(in)
|
||||
if len(out) != 2*len(in) {
|
||||
t.Fatalf("upsample2 returned %d samples for %d", len(out), len(in))
|
||||
}
|
||||
|
||||
wanted := goertzel(out, 2*fs, tone)
|
||||
image := goertzel(out, 2*fs, 2*fs-tone-16000) // = 10 kHz
|
||||
if wanted == 0 {
|
||||
t.Fatal("the tone itself did not survive upsampling")
|
||||
}
|
||||
db := 20 * math.Log10(image/wanted)
|
||||
t.Logf("image at 10 kHz is %.1f dB below the 6 kHz tone", db)
|
||||
if db > -35 {
|
||||
t.Errorf("image only %.1f dB down — it is audible as added hiss; want at least 35 dB", db)
|
||||
}
|
||||
}
|
||||
|
||||
// The filter must not change the level, or every existing recording would come
|
||||
// back quieter (or clipped) after the fix — a second surprise on top of the one
|
||||
// being repaired.
|
||||
func TestUpsampleKeepsTheLevel(t *testing.T) {
|
||||
const fs = 16000.0
|
||||
in := make([]int16, 4096)
|
||||
for i := range in {
|
||||
in[i] = int16(10000 * math.Sin(2*math.Pi*1000*float64(i)/fs))
|
||||
}
|
||||
rms := func(x []int16) float64 {
|
||||
var acc float64
|
||||
for _, v := range x {
|
||||
acc += float64(v) * float64(v)
|
||||
}
|
||||
return math.Sqrt(acc / float64(len(x)))
|
||||
}
|
||||
// Skip the filter's warm-up and tail, where the window is only partly fed.
|
||||
out := upsample2(in)
|
||||
got, want := rms(out[200:len(out)-200]), rms(in[100:len(in)-100])
|
||||
ratio := got / want
|
||||
t.Logf("level after upsampling: ×%.3f", ratio)
|
||||
if ratio < 0.9 || ratio > 1.1 {
|
||||
t.Errorf("level changed by ×%.3f — want within 10%%", ratio)
|
||||
}
|
||||
}
|
||||
@@ -33,13 +33,13 @@ func writeWAV(path string, pcm []byte) error {
|
||||
put(uint32(36 + dataLen))
|
||||
f.WriteString("WAVE")
|
||||
f.WriteString("fmt ")
|
||||
put(uint32(16)) // PCM fmt chunk size
|
||||
put(uint16(1)) // WAVE_FORMAT_PCM
|
||||
put(uint16(channels)) //
|
||||
put(uint32(sampleRate)) //
|
||||
put(uint32(bytesPerSec)) // byte rate
|
||||
put(uint16(blockAlign)) //
|
||||
put(uint16(bitsPerSample)) //
|
||||
put(uint32(16)) // PCM fmt chunk size
|
||||
put(uint16(1)) // WAVE_FORMAT_PCM
|
||||
put(uint16(channels)) //
|
||||
put(uint32(sampleRate)) //
|
||||
put(uint32(bytesPerSec)) // byte rate
|
||||
put(uint16(blockAlign)) //
|
||||
put(uint16(bitsPerSample)) //
|
||||
f.WriteString("data")
|
||||
put(uint32(dataLen))
|
||||
_, err = f.Write(pcm)
|
||||
|
||||
+97
-14
@@ -75,10 +75,22 @@ type Def struct {
|
||||
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
||||
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
||||
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
||||
// NOTE: there is no "one reference per QSO" switch, and there was never any
|
||||
// point in one. A QSO ALWAYS yields every reference its field holds — an n-fer
|
||||
// POTA activation ("US-6544,US-0680"), a VUCC contact on a grid line. The old
|
||||
// `Multi` flag was read by nothing; its checkbox changed nothing.
|
||||
// OneRefPerQSO refuses an AMBIGUOUS match rather than guessing.
|
||||
//
|
||||
// This is not the old `Multi` flag, which was removed for good reason: a QSO
|
||||
// always yields every reference its FIELD holds — an n-fer POTA activation
|
||||
// ("US-6544,US-0680"), a VUCC contact on a grid line. That is several
|
||||
// references, correctly.
|
||||
//
|
||||
// The case here is the opposite. The field holds ONE value and two entries in
|
||||
// the reference list carry it: two German DOKs are both called "Gießen",
|
||||
// because two clubs share the town. The matcher is not wrong, the data is
|
||||
// ambiguous — and picking one would write a reference into the log that may
|
||||
// well be the other. So on an award where a QSO can only ever count for one
|
||||
// reference, an ambiguous match assigns NONE and the contact appears under
|
||||
// "missing references", where the operator picks. Their choice then sticks:
|
||||
// a manual reference overrides the matcher.
|
||||
OneRefPerQSO bool `json:"one_ref_per_qso,omitempty"`
|
||||
|
||||
// OrRules are ordered FALLBACK searches for the primary one above: they are
|
||||
// tried IN ORDER and only while nothing has matched yet — the first rule that
|
||||
@@ -334,13 +346,19 @@ type BandCount struct {
|
||||
|
||||
// Ref is one reference's status within an award.
|
||||
type Ref struct {
|
||||
Ref string `json:"ref"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
SubGrp string `json:"subgrp,omitempty"`
|
||||
Worked bool `json:"worked"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
Validated bool `json:"validated"`
|
||||
Ref string `json:"ref"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
SubGrp string `json:"subgrp,omitempty"`
|
||||
Worked bool `json:"worked"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
Validated bool `json:"validated"`
|
||||
// Modes / ConfirmedModes are CLASSES, not ADIF modes: "CW", "PHONE",
|
||||
// "DIGI". An operator asking "which entities have I worked on CW but not
|
||||
// confirmed" does not care whether it was FT8 or RTTY on the digital side,
|
||||
// and a list of twenty mode names would not answer the question they asked.
|
||||
Modes []string `json:"modes"`
|
||||
ConfirmedModes []string `json:"confirmed_modes"`
|
||||
Bands []string `json:"bands"`
|
||||
ConfirmedBands []string `json:"confirmed_bands"`
|
||||
ValidatedBands []string `json:"validated_bands"`
|
||||
@@ -360,11 +378,33 @@ type Result struct {
|
||||
Error string `json:"error,omitempty"` // e.g. bad regexp pattern
|
||||
}
|
||||
|
||||
// ModeClass sorts an ADIF mode into the three classes an operator thinks in:
|
||||
// CW, PHONE, DIGI. Anything unrecognised returns "" and is simply not counted
|
||||
// under any class — better than inventing one, since these classes drive a
|
||||
// filter that decides what the operator is shown.
|
||||
func ModeClass(mode string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "CW", "CWR":
|
||||
return "CW"
|
||||
case "SSB", "USB", "LSB", "AM", "FM", "DV", "PHONE", "DIGITALVOICE":
|
||||
return "PHONE"
|
||||
case "":
|
||||
return ""
|
||||
}
|
||||
// Everything else that a logbook actually carries is a data mode: FT8, FT4,
|
||||
// RTTY, PSK31, JS8, Q65, MSK144, OLIVIA, VARA… Listing them exhaustively
|
||||
// would mean a new mode silently vanishing from the filter the day it
|
||||
// appears, which is worse than treating an unknown data mode as data.
|
||||
return "DIGI"
|
||||
}
|
||||
|
||||
// NameResolver optionally maps a (field, ref) pair to a human name. May be nil.
|
||||
type NameResolver func(field, ref string) string
|
||||
|
||||
type refAgg struct {
|
||||
bands map[string]struct{}
|
||||
modes map[string]struct{}
|
||||
confirmedModes map[string]struct{}
|
||||
confirmedBands map[string]struct{}
|
||||
validatedBands map[string]struct{}
|
||||
anyConfirmed bool
|
||||
@@ -481,22 +521,33 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
||||
continue
|
||||
}
|
||||
band := strings.ToLower(strings.TrimSpace(q.Band))
|
||||
modeClass := ModeClass(q.Mode)
|
||||
isConf := confirmed(q, d.Confirm)
|
||||
isVal := confirmed(q, d.Validate)
|
||||
for _, ref := range refs {
|
||||
a := agg[i][ref]
|
||||
if a == nil {
|
||||
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}, validatedBands: map[string]struct{}{}}
|
||||
a = &refAgg{
|
||||
bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{},
|
||||
validatedBands: map[string]struct{}{},
|
||||
modes: map[string]struct{}{}, confirmedModes: map[string]struct{}{},
|
||||
}
|
||||
agg[i][ref] = a
|
||||
}
|
||||
if band != "" {
|
||||
a.bands[band] = struct{}{}
|
||||
}
|
||||
if modeClass != "" {
|
||||
a.modes[modeClass] = struct{}{}
|
||||
}
|
||||
if isConf {
|
||||
a.anyConfirmed = true
|
||||
if band != "" {
|
||||
a.confirmedBands[band] = struct{}{}
|
||||
}
|
||||
if modeClass != "" {
|
||||
a.confirmedModes[modeClass] = struct{}{}
|
||||
}
|
||||
}
|
||||
if isVal {
|
||||
a.anyValidated = true
|
||||
@@ -525,6 +576,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
||||
r.Validated++
|
||||
}
|
||||
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
|
||||
Modes: setToSorted(a.modes), ConfirmedModes: setToSorted(a.confirmedModes),
|
||||
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands), ValidatedBands: setToSorted(a.validatedBands)}
|
||||
labelRef(&rf, d, ref, rl, hasList, nameOf)
|
||||
r.Refs = append(r.Refs, rf)
|
||||
@@ -819,7 +871,15 @@ type Explanation struct {
|
||||
RefCount int `json:"ref_count"` // size of that list
|
||||
Steps []Step `json:"steps"`
|
||||
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
|
||||
Result []string `json:"result"` // what the QSO finally counts for
|
||||
// Superseded is what the matcher had found before the operator's choice
|
||||
// replaced it — shown in the trace so a correction is explainable rather
|
||||
// than mysterious.
|
||||
Superseded []string `json:"superseded,omitempty"`
|
||||
// Ambiguous lists the references that matched when the award allows only one
|
||||
// — none of them was kept. Shown so the operator sees WHICH ones to choose
|
||||
// between rather than an unexplained blank.
|
||||
Ambiguous []string `json:"ambiguous,omitempty"`
|
||||
Result []string `json:"result"` // what the QSO finally counts for
|
||||
}
|
||||
|
||||
// Explain runs the matcher on a single QSO and reports what it did. It goes
|
||||
@@ -937,8 +997,31 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
|
||||
if ex != nil {
|
||||
ex.Manual = manual
|
||||
}
|
||||
found = append(found, manual...)
|
||||
// An operator's choice REPLACES what the matcher found. It does not join it.
|
||||
//
|
||||
// Adding to it was the behaviour, and it made a correction impossible: a
|
||||
// German address matching two or three DOKs kept them all, so deleting the
|
||||
// wrong ones and assigning the right one lasted exactly until the next
|
||||
// recompute, which put them straight back. The operator was arguing with
|
||||
// the matcher and could not win.
|
||||
//
|
||||
// One manual entry is stored per award (setOverrideRef drops the previous
|
||||
// one), so this is an override in the storage as well as in the name.
|
||||
if len(manual) > 0 {
|
||||
if ex != nil {
|
||||
ex.Superseded = dedupe(found)
|
||||
}
|
||||
found = manual
|
||||
}
|
||||
out := dedupe(found)
|
||||
// Ambiguity is not a result. See OneRefPerQSO.
|
||||
if d.OneRefPerQSO && len(manual) == 0 && len(out) > 1 {
|
||||
if ex != nil {
|
||||
ex.Ambiguous = out
|
||||
ex.Result = []string{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if ex != nil {
|
||||
ex.Result = out
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
package award
|
||||
|
||||
import "testing"
|
||||
|
||||
// The three classes an operator thinks in — the ones the awards filter offers.
|
||||
//
|
||||
// "Which entities have I worked on CW but not confirmed" is the question this
|
||||
// serves, so the classes must match how the question is asked: CW, phone, data.
|
||||
// Which data mode it was does not enter into it.
|
||||
func TestModeClass(t *testing.T) {
|
||||
for _, c := range []struct{ mode, want string }{
|
||||
{"CW", "CW"}, {"cw", "CW"}, {" CW ", "CW"}, {"CWR", "CW"},
|
||||
{"SSB", "PHONE"}, {"USB", "PHONE"}, {"LSB", "PHONE"},
|
||||
{"AM", "PHONE"}, {"FM", "PHONE"}, {"DV", "PHONE"},
|
||||
{"FT8", "DIGI"}, {"FT4", "DIGI"}, {"RTTY", "DIGI"}, {"PSK31", "DIGI"},
|
||||
{"JS8", "DIGI"}, {"Q65", "DIGI"}, {"OLIVIA", "DIGI"},
|
||||
// A mode nobody has heard of yet is data, not nothing: the alternative
|
||||
// is a new digital mode silently vanishing from the filter the day it
|
||||
// appears.
|
||||
{"SOMETHINGNEW", "DIGI"},
|
||||
// No mode at all is not a class, and must not be counted as one.
|
||||
{"", ""}, {" ", ""},
|
||||
} {
|
||||
if got := ModeClass(c.mode); got != c.want {
|
||||
t.Errorf("ModeClass(%q) = %q, want %q", c.mode, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package award
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// An operator's correction must survive a recompute.
|
||||
//
|
||||
// Reported on the DARC DOK award: a German address matches two or three DOKs,
|
||||
// the operator deletes the wrong ones and assigns the right one, and the next
|
||||
// recompute puts the wrong ones straight back. The manual reference was being
|
||||
// ADDED to what the matcher found instead of replacing it, so the correction
|
||||
// could never win.
|
||||
func TestManualReferenceReplacesTheMatch(t *testing.T) {
|
||||
// An award that reads the address and matches a reference's description —
|
||||
// the shape the DOK award uses, and the shape that over-matches.
|
||||
def := Def{
|
||||
Code: "DLD", Name: "DARC DOK", Field: "address",
|
||||
MatchBy: "description", Type: "QSOFIELDS", Valid: true,
|
||||
}
|
||||
metas := []RefMeta{
|
||||
{Code: "A01", Name: "Berlin", Valid: true},
|
||||
{Code: "B05", Name: "Berlin Mitte", Valid: true},
|
||||
}
|
||||
// An address that legitimately matches both.
|
||||
q := &qso.QSO{
|
||||
Callsign: "DL2FDM",
|
||||
Address: "Berlin Mitte, 10115 Berlin",
|
||||
}
|
||||
|
||||
auto := Explain(def, metas, q)
|
||||
if len(auto.Result) < 2 {
|
||||
t.Fatalf("the test needs an over-matching case; got %v", auto.Result)
|
||||
}
|
||||
|
||||
// The operator picks the right one.
|
||||
q.Extras = map[string]string{ManualRefsKey: "DLD@B05"}
|
||||
fixed := Explain(def, metas, q)
|
||||
|
||||
if got := strings.Join(fixed.Result, ","); got != "B05" {
|
||||
t.Errorf("after the correction the QSO counts for %q — want B05 alone", got)
|
||||
}
|
||||
if len(fixed.Superseded) == 0 {
|
||||
t.Error("the trace does not say what the correction replaced")
|
||||
}
|
||||
|
||||
// A QSO with no correction still gets everything the matcher found: the
|
||||
// override must not disable matching for everyone else.
|
||||
q2 := &qso.QSO{Callsign: "DL3XYZ", Address: "Berlin Mitte, 10115 Berlin"}
|
||||
if len(Explain(def, metas, q2).Result) < 2 {
|
||||
t.Error("an untouched QSO lost its automatic references")
|
||||
}
|
||||
}
|
||||
|
||||
// On an award where a QSO can only count for ONE reference, an ambiguous match
|
||||
// assigns none.
|
||||
//
|
||||
// Two DARC DOKs are called "Gießen" because two clubs share the town. Exact
|
||||
// matching finds both, and both are right as far as the matcher can tell.
|
||||
// Picking one would write a reference into the log that may well be the other —
|
||||
// so the contact is left for the operator, who now has the last word.
|
||||
func TestAmbiguousMatchIsRefusedWhenOnlyOneReferenceIsAllowed(t *testing.T) {
|
||||
def := Def{
|
||||
Code: "DLD", Name: "DARC DOK", Field: "qth",
|
||||
MatchBy: "description", ExactMatch: true, Type: "QSOFIELDS", Valid: true,
|
||||
OneRefPerQSO: true,
|
||||
}
|
||||
metas := []RefMeta{
|
||||
{Code: "F07", Name: "Gießen", Valid: true},
|
||||
{Code: "F61", Name: "Gießen", Valid: true},
|
||||
{Code: "B11", Name: "Kassel", Valid: true},
|
||||
}
|
||||
|
||||
amb := Explain(def, metas, &qso.QSO{Callsign: "DL2FDM", QTH: "Gießen"})
|
||||
if len(amb.Result) != 0 {
|
||||
t.Errorf("an ambiguous match produced %v — it should produce nothing", amb.Result)
|
||||
}
|
||||
if len(amb.Ambiguous) != 2 {
|
||||
t.Errorf("the trace lists %v — it should name both candidates", amb.Ambiguous)
|
||||
}
|
||||
|
||||
// An unambiguous one is untouched: this must not make the award stricter in
|
||||
// general, only where the data genuinely does not decide.
|
||||
ok := Explain(def, metas, &qso.QSO{Callsign: "DL3XYZ", QTH: "Kassel"})
|
||||
if len(ok.Result) != 1 || ok.Result[0] != "B11" {
|
||||
t.Errorf("an unambiguous QSO gave %v — want B11", ok.Result)
|
||||
}
|
||||
|
||||
// And the operator's choice still wins over the ambiguity.
|
||||
fixed := Explain(def, metas, &qso.QSO{
|
||||
Callsign: "DL2FDM", QTH: "Gießen",
|
||||
Extras: map[string]string{ManualRefsKey: "DLD@F61"},
|
||||
})
|
||||
if len(fixed.Result) != 1 || fixed.Result[0] != "F61" {
|
||||
t.Errorf("after the operator picked F61 the QSO counts for %v", fixed.Result)
|
||||
}
|
||||
}
|
||||
+84
-21
@@ -35,7 +35,12 @@ type Flex struct {
|
||||
model string
|
||||
gotHandle bool
|
||||
|
||||
slices map[int]*flexSlice
|
||||
slices map[int]*flexSlice
|
||||
// pinnedSlice is the slice the operator chose IN OPSLOG (-1 = none). It
|
||||
// overrides the radio's own "active" flag, and only an OpsLog click changes
|
||||
// it — activating a slice on the radio's front panel or in SmartSDR does
|
||||
// not, by design (see mainSliceLocked).
|
||||
pinnedSlice int
|
||||
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
||||
amp flexAmp // external amplifier (PowerGenius XL) state
|
||||
micProfiles []string // available mic profiles (SmartSDR "profile mic list")
|
||||
@@ -183,6 +188,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +314,12 @@ func (f *Flex) send(cmd string) int {
|
||||
debugLog.Printf("Flex: send %q failed: %v", cmd, err)
|
||||
return 0
|
||||
}
|
||||
debugLog.Printf("Flex: → %s", cmd)
|
||||
// Meter subscriptions are pure bookkeeping and a Flex announces meters one
|
||||
// status line at a time — tracing each "sub meter N" wrote dozens of lines
|
||||
// at every connect.
|
||||
if !strings.HasPrefix(cmd, "sub meter ") {
|
||||
debugLog.Printf("Flex: → %s", cmd)
|
||||
}
|
||||
return seq
|
||||
}
|
||||
|
||||
@@ -767,17 +778,11 @@ func (f *Flex) handleStatus(payload string) {
|
||||
f.meterMeta[num] = old
|
||||
}
|
||||
f.mu.Unlock()
|
||||
// One line for the whole batch, not one per meter: a Flex announces
|
||||
// dozens at connect and that was a wall of text every time.
|
||||
var names []string
|
||||
// Not logged: the radio announces meters ONE per status line, so even
|
||||
// a batched line meant dozens of them at every connect.
|
||||
for _, id := range newIDs {
|
||||
mi := f.meterMeta[id]
|
||||
names = append(names, nonEmpty(mi.name, strconv.Itoa(id)))
|
||||
f.subscribeMeter(id)
|
||||
}
|
||||
if len(names) > 0 {
|
||||
debugLog.Printf("Flex: subscribed to %d meter(s): %s", len(names), strings.Join(names, " "))
|
||||
}
|
||||
}
|
||||
// Spot status: "spot <index> …". Track the index so we can clear the
|
||||
// panadapter, and log it verbatim — a click on a panadapter spot pushes a
|
||||
@@ -968,7 +973,17 @@ func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
||||
// map order returned a RANDOM active slice each call → the operating frequency
|
||||
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
|
||||
// Deterministic order = the lowest-indexed active slice wins, stably.
|
||||
// An explicit choice made in OpsLog wins over everything, including the
|
||||
// radio's active flag. It is dropped only when that slice stops being in
|
||||
// use — a slice the operator closed is not a choice any more.
|
||||
if f.pinnedSlice >= 0 {
|
||||
if s := f.slices[f.pinnedSlice]; s != nil && s.inUse {
|
||||
return f.pinnedSlice, s
|
||||
}
|
||||
}
|
||||
|
||||
firstInUse := -1
|
||||
chosen := -1
|
||||
for _, idx := range f.sortedSliceIdxLocked() {
|
||||
s := f.slices[idx]
|
||||
if !s.inUse {
|
||||
@@ -978,11 +993,56 @@ func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
||||
firstInUse = idx
|
||||
}
|
||||
if s.active {
|
||||
return idx, s
|
||||
chosen = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstInUse >= 0 {
|
||||
return firstInUse, f.slices[firstInUse]
|
||||
if chosen < 0 {
|
||||
chosen = firstInUse
|
||||
}
|
||||
if chosen < 0 {
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
// In SPLIT, stay on the RECEIVE slice.
|
||||
//
|
||||
// SmartSDR moves its "active" flag to the TX slice as soon as the transmit
|
||||
// frequency is touched, so OpsLog followed the transmitter. But the operator
|
||||
// is LISTENING to the DX on the other slice: the S-meter, the audio level,
|
||||
// the filter and the DSP they are adjusting all belong there, and following
|
||||
// the TX slice hands them the controls for a receiver they are not using.
|
||||
//
|
||||
// Only in split, and only when nothing was pinned above — clicking slice B
|
||||
// in OpsLog still selects it and keeps it.
|
||||
if txS := f.txSliceLocked(); txS != nil && f.slices[chosen] == txS {
|
||||
if rxIdx, rxS := f.splitPartnerLocked(txS); rxS != nil {
|
||||
return rxIdx, rxS
|
||||
}
|
||||
}
|
||||
return chosen, f.slices[chosen]
|
||||
}
|
||||
|
||||
// splitPartnerLocked returns the slice that FORMS A SPLIT with txS: in use, on
|
||||
// the same band, at a different frequency, in the same class (phone with phone,
|
||||
// CW with CW — so SSB alongside FT8 on one band is not a split).
|
||||
//
|
||||
// Lowest index first, so the answer is stable; map order is randomised in Go
|
||||
// and gave a different partner on each poll. Caller holds f.mu.
|
||||
func (f *Flex) splitPartnerLocked(txS *flexSlice) (int, *flexSlice) {
|
||||
if txS == nil {
|
||||
return -1, nil
|
||||
}
|
||||
bt := BandFromHz(txS.freqHz)
|
||||
ct := flexSplitClass(txS.mode)
|
||||
if bt == "" || ct == "" {
|
||||
return -1, nil
|
||||
}
|
||||
for _, idx := range f.sortedSliceIdxLocked() {
|
||||
s := f.slices[idx]
|
||||
if s != nil && s.inUse && s != txS && s.freqHz != txS.freqHz &&
|
||||
BandFromHz(s.freqHz) == bt && flexSplitClass(s.mode) == ct {
|
||||
return idx, s
|
||||
}
|
||||
}
|
||||
return -1, nil
|
||||
}
|
||||
@@ -1089,6 +1149,11 @@ func (f *Flex) SetActiveSlice(idx int) error {
|
||||
if !exists {
|
||||
return fmt.Errorf("flex: no slice %d", idx)
|
||||
}
|
||||
// Remember it: this is the operator speaking, and it must survive the radio
|
||||
// moving its own active flag to the transmitter during split.
|
||||
f.mu.Lock()
|
||||
f.pinnedSlice = idx
|
||||
f.mu.Unlock()
|
||||
f.send(fmt.Sprintf("slice s %d active=1", idx))
|
||||
return nil
|
||||
}
|
||||
@@ -1389,6 +1454,7 @@ func (f *Flex) FlexState() FlexTXState {
|
||||
}
|
||||
}
|
||||
sort.Ints(sidx)
|
||||
mainIdx, _ := f.mainSliceLocked()
|
||||
for _, i := range sidx {
|
||||
s := f.slices[i]
|
||||
st.Slices = append(st.Slices, FlexSliceInfo{
|
||||
@@ -1397,7 +1463,11 @@ func (f *Flex) FlexState() FlexTXState {
|
||||
FreqHz: s.freqHz,
|
||||
Mode: flexModeToADIF(s.mode),
|
||||
Band: BandFromHz(s.freqHz),
|
||||
Active: s.active,
|
||||
// The slice OpsLog is working with, which is not always the one the
|
||||
// radio has focused — in split OpsLog stays on RX. Reporting the
|
||||
// radio's flag here would highlight one slice while every control
|
||||
// acted on another.
|
||||
Active: i == mainIdx,
|
||||
TX: s.tx,
|
||||
})
|
||||
}
|
||||
@@ -2190,13 +2260,6 @@ func (f *Flex) subscribeMeter(id int) {
|
||||
f.send(fmt.Sprintf("sub meter %d", id))
|
||||
}
|
||||
|
||||
func nonEmpty(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseFloatDefault(s string, def float64) float64 {
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
|
||||
return v
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package cat
|
||||
|
||||
import "testing"
|
||||
|
||||
// In split, OpsLog stays on the RECEIVE slice.
|
||||
//
|
||||
// SmartSDR moves its "active" flag onto the TX slice as soon as the transmit
|
||||
// frequency is touched. Following it hands the operator the S-meter, audio
|
||||
// level, filter and DSP of a receiver they are not listening to — while the DX
|
||||
// they are working is on the other slice.
|
||||
func TestMainSliceStaysOnRXInSplit(t *testing.T) {
|
||||
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
|
||||
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
|
||||
}
|
||||
|
||||
// Working a DX on 14.100, transmitting up on 14.200. The radio has focused
|
||||
// the transmitter.
|
||||
f := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
|
||||
0: mk(14_100_000, "USB", false, false), // where the DX is heard
|
||||
1: mk(14_200_000, "USB", true, true), // where we transmit, radio-focused
|
||||
}}
|
||||
idx, s := f.mainSliceLocked()
|
||||
if idx != 0 || s == nil || s.freqHz != 14_100_000 {
|
||||
t.Errorf("main slice = %d (%v Hz) — want slice 0, the RX side on 14.100", idx, s.freqHz)
|
||||
}
|
||||
|
||||
// Simplex: the radio's focus is authoritative again, nothing to prefer.
|
||||
f2 := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
|
||||
0: mk(14_100_000, "USB", false, false),
|
||||
1: mk(14_200_000, "USB", true, true),
|
||||
}}
|
||||
f2.slices[0].inUse = false // only the TX slice is in use → simplex
|
||||
if idx, _ := f2.mainSliceLocked(); idx != 1 {
|
||||
t.Errorf("simplex main slice = %d, want 1 — the only slice in use", idx)
|
||||
}
|
||||
|
||||
// Two same-band slices in DIFFERENT classes are not a split (SSB + FT8), so
|
||||
// there is nothing to prefer and the radio's focus stands.
|
||||
f3 := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
|
||||
0: mk(14_074_000, "DIGU", false, false),
|
||||
1: mk(14_200_000, "USB", true, true),
|
||||
}}
|
||||
if idx, _ := f3.mainSliceLocked(); idx != 1 {
|
||||
t.Errorf("SSB+FT8 main slice = %d, want 1 — that is not a split", idx)
|
||||
}
|
||||
}
|
||||
|
||||
// A slice picked IN OPSLOG wins over everything, including the split rule and
|
||||
// the radio's own focus. Picking one on the radio does not move OpsLog.
|
||||
func TestPinnedSliceWins(t *testing.T) {
|
||||
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
|
||||
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
|
||||
}
|
||||
f := &Flex{pinnedSlice: 1, slices: map[int]*flexSlice{
|
||||
0: mk(14_100_000, "USB", true, false), // radio-focused
|
||||
1: mk(14_200_000, "USB", false, true), // chosen in OpsLog
|
||||
}}
|
||||
if idx, _ := f.mainSliceLocked(); idx != 1 {
|
||||
t.Errorf("main slice = %d — an explicit choice in OpsLog must win", idx)
|
||||
}
|
||||
|
||||
// A pin on a slice that is no longer in use is not a choice any more: it
|
||||
// must not strand OpsLog on a receiver that has been closed.
|
||||
f.slices[1].inUse = false
|
||||
if idx, _ := f.mainSliceLocked(); idx != 0 {
|
||||
t.Errorf("main slice = %d — a closed slice cannot stay pinned", idx)
|
||||
}
|
||||
}
|
||||
+66
-15
@@ -114,6 +114,15 @@ type IcomSerial struct {
|
||||
silentGrace time.Duration // current width of that tolerance (backs off, see ReadState)
|
||||
dspLoaded bool // readDSP has run since the rig became responsive (loads all
|
||||
// the panel's set-once controls once the rig actually answers)
|
||||
// When the console last asked for the DSP snapshot. The meters and the
|
||||
// front-panel rotation are polled ONLY while something is displaying them:
|
||||
// they are half of OpsLog's CI-V traffic, and on a shared bus — a microHAM
|
||||
// Router splitting one CI-V link into two virtual ports, OpsLog on one and
|
||||
// WSJT-X/MSHV on the other — every frame we don't need is a frame that can
|
||||
// collide with the other program's.
|
||||
dspWantMu sync.Mutex
|
||||
dspWantAt time.Time
|
||||
|
||||
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
|
||||
lastSetFreqAt time.Time
|
||||
|
||||
@@ -409,15 +418,25 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
}
|
||||
|
||||
// Live meters + TX state for the Icom panel (the rig doesn't push these).
|
||||
// The meters are polled only while the console is actually on screen: they
|
||||
// were three CI-V round trips per cycle, every cycle, whether or not anyone
|
||||
// could see them. The previous readings are kept so the panel opens on the
|
||||
// last known values rather than on zeros.
|
||||
tx := b.readTX()
|
||||
sm, _ := b.readMeter(civ.SubMeterS)
|
||||
po, swr := 0, 0
|
||||
if tx {
|
||||
if v, ok := b.readMeter(civ.SubMeterPo); ok {
|
||||
po = v
|
||||
}
|
||||
if v, ok := b.readMeter(civ.SubMeterSWR); ok {
|
||||
swr = v
|
||||
watched := b.panelWatched()
|
||||
b.dspMu.Lock()
|
||||
sm, po, swr := b.dsp.SMeter, b.dsp.PowerMeter, b.dsp.SWRMeter
|
||||
b.dspMu.Unlock()
|
||||
if watched {
|
||||
sm, _ = b.readMeter(civ.SubMeterS)
|
||||
po, swr = 0, 0
|
||||
if tx {
|
||||
if v, ok := b.readMeter(civ.SubMeterPo); ok {
|
||||
po = v
|
||||
}
|
||||
if v, ok := b.readMeter(civ.SubMeterSWR); ok {
|
||||
swr = v
|
||||
}
|
||||
}
|
||||
}
|
||||
b.dspMu.Lock()
|
||||
@@ -434,11 +453,15 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
// snapshot so the panel's antenna, sliders, RIT, notch, etc. reflect the rig
|
||||
// instead of sitting at their zero defaults. Runs once; ↻ Refresh re-reads on
|
||||
// demand, and a reconnect re-arms it (Connect clears dspLoaded).
|
||||
if !b.dspLoaded {
|
||||
b.readDSP()
|
||||
b.dspLoaded = true
|
||||
} else {
|
||||
b.refreshFrontPanel()
|
||||
// Both of these only make sense for the console — don't spend the bus on them
|
||||
// while it is closed. The snapshot then loads the first time it is opened.
|
||||
if watched {
|
||||
if !b.dspLoaded {
|
||||
b.readDSP()
|
||||
b.dspLoaded = true
|
||||
} else {
|
||||
b.refreshFrontPanel()
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -532,12 +555,25 @@ func (b *IcomSerial) SetPower(on bool) error {
|
||||
return fmt.Errorf("icom: not connected")
|
||||
}
|
||||
if on {
|
||||
buf := make([]byte, 0, 32)
|
||||
for i := 0; i < 25; i++ {
|
||||
// The preamble has to last long enough IN TIME for the sleeping rig to
|
||||
// notice it, so its length scales with the baud rate — Icom's own table
|
||||
// asks for 7 bytes at 4800 baud and 150 at 115200, which is a fixed
|
||||
// ~25 ms of carrier. A flat 25 bytes (what this sent before) is right at
|
||||
// 19200 and far too short above it, so a rig configured for 115200 simply
|
||||
// ignored the command.
|
||||
n := b.baud / 768
|
||||
if n < 7 {
|
||||
n = 7
|
||||
} else if n > 150 {
|
||||
n = 150
|
||||
}
|
||||
buf := make([]byte, 0, n+8)
|
||||
for i := 0; i < n; i++ {
|
||||
buf = append(buf, 0xFE)
|
||||
}
|
||||
buf = append(buf, 0xFE, 0xFE, b.rigAddr, civ.AddrController, civ.CmdPower, 0x01, 0xFD)
|
||||
_, err := b.port.Write(buf)
|
||||
applog.Printf("icom: power ON — %d-byte wake preamble at %d baud, addr 0x%02X (err=%v)", n, b.baud, b.rigAddr, err)
|
||||
return err
|
||||
}
|
||||
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, civ.CmdPower, 0x00))
|
||||
@@ -1289,11 +1325,26 @@ func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
|
||||
// ── IcomController: receive-DSP controls for the Icom tab ───────────────────
|
||||
|
||||
func (b *IcomSerial) IcomState() IcomTXState {
|
||||
// Asking for the snapshot is what marks the console as being watched, which
|
||||
// is what re-enables the meter polling in ReadState.
|
||||
b.dspWantMu.Lock()
|
||||
b.dspWantAt = time.Now()
|
||||
b.dspWantMu.Unlock()
|
||||
|
||||
b.dspMu.Lock()
|
||||
defer b.dspMu.Unlock()
|
||||
return b.dsp
|
||||
}
|
||||
|
||||
// panelWatched reports whether the Icom console asked for the DSP snapshot
|
||||
// recently — i.e. whether anything is on screen to read the meters.
|
||||
func (b *IcomSerial) panelWatched() bool {
|
||||
b.dspWantMu.Lock()
|
||||
at := b.dspWantAt
|
||||
b.dspWantMu.Unlock()
|
||||
return !at.IsZero() && time.Since(at) < 3*time.Second
|
||||
}
|
||||
|
||||
// RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT
|
||||
// goroutine (dispatched via IcomDo).
|
||||
func (b *IcomSerial) RefreshIcom() error {
|
||||
|
||||
+275
-20
@@ -29,7 +29,10 @@ package cat
|
||||
// from the radio, and the rig file decides what a "Freq" property means.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -43,17 +46,85 @@ import (
|
||||
type Kenwood struct {
|
||||
portName string
|
||||
baud int
|
||||
digital string // mode name logged for data (FT8 by default)
|
||||
// host is "address:port" for a serial link reached over the network — a
|
||||
// ser2net daemon, an Ethernet-serial adapter, a Raspberry Pi in the shack.
|
||||
//
|
||||
// This is NOT Kenwood's own network protocol. A TS-890 or TS-990 speaks
|
||||
// KNS/ARCP over its Ethernet socket, with a session and authentication, and
|
||||
// that is a different piece of work needing one of those radios to confirm
|
||||
// it. What this covers is the same CAT byte stream over a socket instead of
|
||||
// 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)
|
||||
|
||||
mu sync.Mutex
|
||||
port serial.Port
|
||||
|
||||
// dialPort, when set, replaces serial.Open. It exists so the backend can be
|
||||
// driven against internal/catemu — which already SPEAKS this dialect to
|
||||
// satisfy an ACOM amplifier — without a radio, a COM port or a null-modem
|
||||
// pair. Written for a Kenwood backend nobody here owns a rig to test.
|
||||
dialPort func() (serial.Port, error)
|
||||
|
||||
model string
|
||||
curFreq int64
|
||||
curRXFreq int64
|
||||
curVFO string // "A" or "B"
|
||||
// Commands this rig answered "?;" to — asked once, then never again.
|
||||
unsupported map[string]bool
|
||||
|
||||
// rx holds bytes read but not yet consumed, ACROSS calls to ask.
|
||||
//
|
||||
// It has to survive: a rig answers faster than we ask, so one Read often
|
||||
// returns a whole reply plus the start of the next frame. When this buffer
|
||||
// was local to ask, everything after the matched frame was dropped — half a
|
||||
// frame included — and the link desynchronised permanently: every ask then
|
||||
// found the PREVIOUS command's answer and timed out waiting for its own.
|
||||
// That is the "discarding \" 000000000010000000;\" while waiting for IF"
|
||||
// a TS-480 reported, followed by connected=false for ever.
|
||||
rx []byte
|
||||
|
||||
// heard is whatever arrived during Connect that was not a reply we wanted.
|
||||
// Kept only to put it in the error message: "not answering" and "answering
|
||||
// something unreadable" are different faults with different fixes.
|
||||
heard string
|
||||
|
||||
// lowerLines deasserts DTR and RTS after opening the port.
|
||||
//
|
||||
// Neither default is safe for everyone, which is why this is a setting and
|
||||
// not a decision. Windows raises both lines on open: an interface that reads
|
||||
// them as PTT then keys the rig for as long as OpsLog is running — reported
|
||||
// as FT8 output power wandering, and gone the moment OpsLog was closed.
|
||||
// Lowering them instead silences the radios whose USB-serial interface needs
|
||||
// RTS asserted to transmit at all — a TS-990 that opened cleanly and answered
|
||||
// nothing. Off by default: that is how this backend behaved for its whole
|
||||
// life before the question came up.
|
||||
lowerLines bool
|
||||
}
|
||||
|
||||
// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
|
||||
// before Connect.
|
||||
func (k *Kenwood) SetLowerLines(v bool) {
|
||||
k.mu.Lock()
|
||||
k.lowerLines = v
|
||||
k.mu.Unlock()
|
||||
}
|
||||
|
||||
// where names the link for a message, so an error does not read "COM @ 0 baud"
|
||||
// after a network connect.
|
||||
func (k *Kenwood) where() string {
|
||||
if k.host != "" {
|
||||
return k.host
|
||||
}
|
||||
return k.portName
|
||||
}
|
||||
|
||||
// NewKenwoodTCP builds a backend that reaches the rig over a socket instead of
|
||||
// a COM port (ser2net and friends).
|
||||
func NewKenwoodTCP(hostPort, digital string) *Kenwood {
|
||||
k := NewKenwood("", 0, digital)
|
||||
k.host = strings.TrimSpace(hostPort)
|
||||
return k
|
||||
}
|
||||
|
||||
func NewKenwood(portName string, baud int, digital string) *Kenwood {
|
||||
@@ -71,8 +142,8 @@ func (k *Kenwood) Name() string { return "kenwood" }
|
||||
func (k *Kenwood) Connect() error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.portName == "" {
|
||||
return fmt.Errorf("kenwood: no serial port configured")
|
||||
if k.portName == "" && k.host == "" {
|
||||
return fmt.Errorf("kenwood: no serial port or network address configured")
|
||||
}
|
||||
// Close any handle still held before opening another: Connect runs again on
|
||||
// every reconnect, and Windows opens a serial port exclusively, so a leaked
|
||||
@@ -82,8 +153,11 @@ func (k *Kenwood) Connect() error {
|
||||
_ = k.port.Close()
|
||||
k.port = nil
|
||||
}
|
||||
p, err := serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
||||
p, err := k.openPort()
|
||||
if err != nil {
|
||||
if k.host != "" {
|
||||
return fmt.Errorf("kenwood: connect %s: %w", k.host, err)
|
||||
}
|
||||
return fmt.Errorf("kenwood: open %s @ %d baud: %w", k.portName, k.baud, err)
|
||||
}
|
||||
p.SetReadTimeout(300 * time.Millisecond)
|
||||
@@ -94,6 +168,14 @@ func (k *Kenwood) Connect() error {
|
||||
// request/response pairs and make a reply impossible to attribute. We poll.
|
||||
_ = k.write("AI0;")
|
||||
|
||||
// Start from silence. A reconnect inherits whatever the rig said last —
|
||||
// unsolicited frames sent before AI0 landed, the tail of an answer nobody
|
||||
// read — and one stale frame is enough to leave every ask one reply behind
|
||||
// its question for the rest of the session.
|
||||
k.rx = nil
|
||||
k.drain(250 * time.Millisecond)
|
||||
k.heard = ""
|
||||
|
||||
answered := false
|
||||
if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") {
|
||||
answered = true
|
||||
@@ -112,9 +194,31 @@ func (k *Kenwood) Connect() error {
|
||||
}
|
||||
if !answered {
|
||||
k.model = ""
|
||||
return fmt.Errorf("kenwood: %s opened but the rig is not answering — check that it is switched on and set to %d baud", k.portName, k.baud)
|
||||
if k.heard == "" && len(k.rx) > 0 {
|
||||
k.heard = string(k.rx) // an unterminated fragment is evidence too
|
||||
}
|
||||
// Distinguish silence from noise. "The rig is not answering" sent an
|
||||
// operator checking the power switch and the baud rate on a radio that was
|
||||
// visibly talking — its frames were arriving, they just did not match what
|
||||
// was asked (wrong baud garbles them; an interface echoing our own
|
||||
// commands back produces the same). Say which of the two it is, and quote
|
||||
// what came back, because that is the fact that decides where to look.
|
||||
if seen := k.heard; seen != "" {
|
||||
return fmt.Errorf("kenwood: %s is sending data but no reply to ID; or IF; — got %q. Check the baud rate (set to %d here) and that nothing else is echoing the port", k.where(), seen, k.baud)
|
||||
}
|
||||
if k.host != "" {
|
||||
return fmt.Errorf("kenwood: %s accepted the connection but the rig sent nothing — check that the serial bridge points at the radio and that the radio is switched on", k.host)
|
||||
}
|
||||
return fmt.Errorf("kenwood: %s opened but the rig sent nothing — check that it is switched on and set to %d baud", k.portName, k.baud)
|
||||
}
|
||||
// Name what was actually connected to. A log reading "connected on @ 0 baud"
|
||||
// after a network connect is the kind of line that sends someone hunting a
|
||||
// serial fault that does not exist.
|
||||
if k.host != "" {
|
||||
debugLog.Printf("kenwood: connected to %s (network serial bridge), model=%q", k.host, k.model)
|
||||
} else {
|
||||
debugLog.Printf("kenwood: connected on %s @ %d baud, model=%q", k.portName, k.baud, k.model)
|
||||
}
|
||||
debugLog.Printf("kenwood: connected on %s @ %d baud, model=%q", k.portName, k.baud, k.model)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -154,6 +258,34 @@ func (k *Kenwood) ReadState() (RigState, error) {
|
||||
// IF reports the frequency of the VFO in USE (what the operator hears).
|
||||
rx := f.FreqHz
|
||||
tx := rx
|
||||
|
||||
// IF's split bit is not filled in by every rig that speaks this dialect —
|
||||
// reported on a Flex through its Kenwood CAT emulation, where the frequency
|
||||
// reads perfectly and split never appears. So ask the question directly as
|
||||
// well: split IS "the transmit VFO differs from the receive VFO", which is
|
||||
// what FR/FT answer, and it is the same rule the Yaesu backend settled on.
|
||||
//
|
||||
// A rig that rejects FR/FT answers "?;" once and is never asked again, so
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
f.Split = split
|
||||
|
||||
if f.Split {
|
||||
// The transmit VFO is the other one. Read it rather than assume, and fall
|
||||
// back to simplex if it cannot be read: a wrong TX frequency is written
|
||||
@@ -229,10 +361,28 @@ func (k *Kenwood) write(cmd string) error {
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
traceText("kenwood", "TX", cmd)
|
||||
_, err := k.port.Write([]byte(cmd))
|
||||
return err
|
||||
}
|
||||
|
||||
// drain reads and throws away whatever the rig has already sent, until it stays
|
||||
// quiet for one read timeout or the budget runs out.
|
||||
func (k *Kenwood) drain(budget time.Duration) {
|
||||
if k.port == nil {
|
||||
return
|
||||
}
|
||||
tmp := make([]byte, 256)
|
||||
deadline := time.Now().Add(budget)
|
||||
for time.Now().Before(deadline) {
|
||||
n, err := k.port.Read(tmp)
|
||||
if err != nil || n == 0 {
|
||||
return // an error here is not interesting: we are throwing this away
|
||||
}
|
||||
traceText("kenwood", "RX-drop", string(tmp[:n]))
|
||||
}
|
||||
}
|
||||
|
||||
// ask sends a query and returns the reply belonging to THAT command. Anything
|
||||
// else on the wire is discarded: a stray frame parsed as a frequency reads as
|
||||
// "lost the rig" to the Manager, which then reconnects — the CAT link dropping
|
||||
@@ -245,25 +395,19 @@ func (k *Kenwood) ask(cmd string) (string, error) {
|
||||
if err := k.write(cmd); err != nil {
|
||||
return "", err
|
||||
}
|
||||
buf := make([]byte, 0, 64)
|
||||
tmp := make([]byte, 64)
|
||||
deadline := time.Now().Add(600 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
n, err := k.port.Read(tmp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n == 0 {
|
||||
continue // read timeout — the rig may still be composing its answer
|
||||
}
|
||||
buf = append(buf, tmp[:n]...)
|
||||
for {
|
||||
// Consume whatever is already buffered BEFORE reading more: the answer
|
||||
// may have arrived attached to the previous one.
|
||||
for {
|
||||
i := strings.IndexByte(string(buf), ';')
|
||||
i := bytes.IndexByte(k.rx, ';')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
frame := string(buf[:i+1])
|
||||
buf = buf[i+1:]
|
||||
frame := string(k.rx[:i+1])
|
||||
k.rx = k.rx[i+1:]
|
||||
traceText("kenwood", "RX", frame)
|
||||
if frame == "?;" {
|
||||
// The rig rejected the command. Remember it so the poll loop stops
|
||||
// paying a 600 ms timeout for it on every cycle.
|
||||
@@ -275,9 +419,25 @@ func (k *Kenwood) ask(cmd string) (string, error) {
|
||||
return frame, nil
|
||||
}
|
||||
debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want)
|
||||
// Remember the first unexpected frame: if the whole handshake fails, this
|
||||
// is what tells the operator the radio was talking after all.
|
||||
if k.heard == "" {
|
||||
k.heard = frame
|
||||
}
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
|
||||
}
|
||||
n, err := k.port.Read(tmp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n > 0 {
|
||||
k.rx = append(k.rx, tmp[:n]...)
|
||||
}
|
||||
// n == 0 is a read timeout, not silence for good: the rig may still be
|
||||
// composing its answer.
|
||||
}
|
||||
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
|
||||
}
|
||||
|
||||
// ── Frame parsing ──────────────────────────────────────────────────────────
|
||||
@@ -407,7 +567,102 @@ var kenwoodModels = map[string]string{
|
||||
"019": "TS-2000",
|
||||
"020": "TS-480",
|
||||
"021": "TS-590S",
|
||||
// 022 reported by a real TS-990S in the field. Kenwood's documentation gives
|
||||
// 024 for that radio, so both are kept: the observed value wins where they
|
||||
// disagree, and neither maps to anything else.
|
||||
"022": "TS-990S",
|
||||
"023": "TS-590SG",
|
||||
"024": "TS-990S",
|
||||
"025": "TS-890S",
|
||||
}
|
||||
|
||||
// openPort opens the serial link, or whatever dialPort provides in a test.
|
||||
func (k *Kenwood) openPort() (serial.Port, error) {
|
||||
if k.dialPort != nil {
|
||||
return k.dialPort()
|
||||
}
|
||||
if k.host != "" {
|
||||
c, err := net.DialTimeout("tcp", k.host, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tcpSerial{conn: c}, nil
|
||||
}
|
||||
p, err := serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The modem lines are only touched when the operator asks for it. See
|
||||
// lowerLines: both defaults break somebody's station.
|
||||
if k.lowerLines {
|
||||
_ = p.SetDTR(false)
|
||||
_ = p.SetRTS(false)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// tcpSerial presents a TCP connection as a serial.Port, so the backend has one
|
||||
// code path whether the rig is on a wire or on the network.
|
||||
//
|
||||
// The modem-control methods are no-ops rather than errors: a bridge has no DTR
|
||||
// to raise, and failing them would break a caller that sets them defensively.
|
||||
type tcpSerial struct{ conn net.Conn }
|
||||
|
||||
func (t *tcpSerial) Read(p []byte) (int, error) {
|
||||
n, err := t.conn.Read(p)
|
||||
// A read deadline expiring is this transport's "no data yet", exactly what a
|
||||
// serial read timeout means to the caller — not a dead link. Reporting it as
|
||||
// an error would make ask() abandon a rig that is merely thinking.
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
func (t *tcpSerial) Write(p []byte) (int, error) { return t.conn.Write(p) }
|
||||
func (t *tcpSerial) Close() error { return t.conn.Close() }
|
||||
func (t *tcpSerial) SetReadTimeout(d time.Duration) error {
|
||||
if d <= 0 {
|
||||
return t.conn.SetReadDeadline(time.Time{})
|
||||
}
|
||||
return t.conn.SetReadDeadline(time.Now().Add(d))
|
||||
}
|
||||
func (t *tcpSerial) SetMode(*serial.Mode) error { return nil }
|
||||
func (t *tcpSerial) Drain() error { return nil }
|
||||
func (t *tcpSerial) ResetInputBuffer() error { return nil }
|
||||
func (t *tcpSerial) ResetOutputBuffer() error { return nil }
|
||||
func (t *tcpSerial) SetDTR(bool) error { return nil }
|
||||
func (t *tcpSerial) SetRTS(bool) error { return nil }
|
||||
func (t *tcpSerial) GetModemStatusBits() (*serial.ModemStatusBits, error) {
|
||||
return &serial.ModemStatusBits{}, nil
|
||||
}
|
||||
func (t *tcpSerial) Break(time.Duration) error { return nil }
|
||||
|
||||
// askVFO asks FR; or FT; and returns "A" or "B".
|
||||
//
|
||||
// The reply is FR0; / FR1; — the digit right after the two-letter command.
|
||||
// Anything else (a rig that answers with more fields, or not at all) returns
|
||||
// false, and the caller keeps whatever IF said rather than inventing a split.
|
||||
func (k *Kenwood) askVFO(cmd string) (string, bool) {
|
||||
r, err := k.ask(cmd)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
want := cmdPrefix(cmd)
|
||||
body := strings.TrimSuffix(strings.TrimPrefix(r, want), ";")
|
||||
if body == "" {
|
||||
return "", false
|
||||
}
|
||||
switch body[0] {
|
||||
case '0':
|
||||
return "A", true
|
||||
case '1':
|
||||
return "B", true
|
||||
}
|
||||
// 2 is "sub receiver" on a TS-2000 — real, but not a VFO we track. Saying
|
||||
// nothing is better than mapping it onto A or B and reporting a split that
|
||||
// does not exist.
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// The Kenwood backend driven against a rig that answers, with no hardware.
|
||||
//
|
||||
// Nobody here owns a Kenwood, and a backend that has never completed a single
|
||||
// exchange is a guess however carefully it was written. But this repository
|
||||
// already contains the other half of the conversation: internal/catemu ANSWERS
|
||||
// this dialect, pretending to be a TS-2000 so an ACOM amplifier follows OpsLog.
|
||||
// The responder below replies exactly as catemu does — same FA/FB widths, same
|
||||
// 38-character IF layout, same ID019 — so the two halves are checked against
|
||||
// each other rather than against my reading of a manual.
|
||||
//
|
||||
// What this proves: the round trip completes, the model is identified, and
|
||||
// frequency, mode, VFO and split come back correct, including the second read
|
||||
// that split requires. What it cannot prove: that a real TS-590 answers on the
|
||||
// same timings, or that its firmware fills every IF field the way the
|
||||
// documentation says. Those still need a radio.
|
||||
|
||||
// fakeSerial is one end of an in-memory serial link. Only Read and Write carry
|
||||
// meaning; the rest satisfy the interface.
|
||||
type fakeSerial struct {
|
||||
mu sync.Mutex
|
||||
toRig *strings.Builder // what the backend has written
|
||||
fromRig []byte // what the rig has queued for the backend
|
||||
answer func(cmd string) string
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (f *fakeSerial) Write(p []byte) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.closed {
|
||||
return 0, fmt.Errorf("closed")
|
||||
}
|
||||
f.toRig.Write(p)
|
||||
// A rig answers as each terminated command arrives.
|
||||
for {
|
||||
s := f.toRig.String()
|
||||
i := strings.IndexByte(s, ';')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
cmd := s[:i+1]
|
||||
rest := s[i+1:]
|
||||
f.toRig.Reset()
|
||||
f.toRig.WriteString(rest)
|
||||
if r := f.answer(cmd); r != "" {
|
||||
f.fromRig = append(f.fromRig, r...)
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (f *fakeSerial) Read(p []byte) (int, error) {
|
||||
// A real port blocks until data or timeout; the backend polls, so returning
|
||||
// (0, nil) on an empty buffer models a read timeout with no bytes.
|
||||
for i := 0; i < 50; i++ {
|
||||
f.mu.Lock()
|
||||
if f.closed {
|
||||
f.mu.Unlock()
|
||||
return 0, fmt.Errorf("closed")
|
||||
}
|
||||
if len(f.fromRig) > 0 {
|
||||
n := copy(p, f.fromRig)
|
||||
f.fromRig = f.fromRig[n:]
|
||||
f.mu.Unlock()
|
||||
return n, nil
|
||||
}
|
||||
f.mu.Unlock()
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (f *fakeSerial) Close() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSerial) SetMode(*serial.Mode) error { return nil }
|
||||
func (f *fakeSerial) Drain() error { return nil }
|
||||
func (f *fakeSerial) ResetInputBuffer() error { return nil }
|
||||
func (f *fakeSerial) ResetOutputBuffer() error { return nil }
|
||||
func (f *fakeSerial) SetDTR(bool) error { return nil }
|
||||
func (f *fakeSerial) SetRTS(bool) error { return nil }
|
||||
func (f *fakeSerial) GetModemStatusBits() (*serial.ModemStatusBits, error) {
|
||||
return &serial.ModemStatusBits{}, nil
|
||||
}
|
||||
func (f *fakeSerial) SetReadTimeout(time.Duration) error { return nil }
|
||||
func (f *fakeSerial) Break(time.Duration) error { return nil }
|
||||
|
||||
// ts2000 answers as internal/catemu does. vfoA/vfoB are the two dials; mode is
|
||||
// the Kenwood digit; split selects which VFO transmits.
|
||||
type ts2000 struct {
|
||||
vfoA, vfoB int64
|
||||
mode byte
|
||||
onB bool
|
||||
split bool
|
||||
// lazyIF models a rig that answers FR/FT correctly but never fills IF's
|
||||
// split bit — the behaviour reported on a Flex through its Kenwood CAT
|
||||
// emulation, where the frequency reads perfectly and split never appears.
|
||||
lazyIF bool
|
||||
// noVFOCmds models a rig that rejects FR/FT outright ("?;").
|
||||
noVFOCmds bool
|
||||
seen []string
|
||||
}
|
||||
|
||||
func (r *ts2000) answer(cmd string) string {
|
||||
r.seen = append(r.seen, cmd)
|
||||
cur := r.vfoA
|
||||
vfoDigit := byte('0')
|
||||
if r.onB {
|
||||
cur, vfoDigit = r.vfoB, '1'
|
||||
}
|
||||
switch {
|
||||
case cmd == "ID;":
|
||||
return "ID019;" // TS-2000, exactly what catemu reports
|
||||
case cmd == "FA;":
|
||||
return fmt.Sprintf("FA%011d;", r.vfoA)
|
||||
case cmd == "FB;":
|
||||
return fmt.Sprintf("FB%011d;", r.vfoB)
|
||||
case cmd == "IF;":
|
||||
split := byte('0')
|
||||
if r.split && !r.lazyIF {
|
||||
split = '1'
|
||||
}
|
||||
// The catemu layout: IF | freq(11) | step(4) | RIT(±5) | 3 | mem(2) |
|
||||
// rx/tx | mode | VFO | scan | split | tone | tone#(2) | shift | ;
|
||||
return fmt.Sprintf("IF%011d%04d%+06d%03d%02d%01d%c%c%01d%c%01d%02d%01d;",
|
||||
cur, 0, 0, 0, 0, 0, r.mode, vfoDigit, 0, split, 0, 0, 0)
|
||||
case strings.HasPrefix(cmd, "FA") && len(cmd) > 3:
|
||||
fmt.Sscanf(cmd, "FA%d;", &r.vfoA)
|
||||
return ""
|
||||
case strings.HasPrefix(cmd, "FB") && len(cmd) > 3:
|
||||
fmt.Sscanf(cmd, "FB%d;", &r.vfoB)
|
||||
return ""
|
||||
case strings.HasPrefix(cmd, "MD") && len(cmd) == 4:
|
||||
r.mode = cmd[2]
|
||||
return ""
|
||||
case cmd == "FR;" || cmd == "FT;":
|
||||
if r.noVFOCmds {
|
||||
return "?;" // rejected, as a rig that does not know the command answers
|
||||
}
|
||||
// FR is the receive VFO, FT the transmit one. They differ exactly when
|
||||
// the rig is in split.
|
||||
v := vfoDigit
|
||||
if cmd == "FT;" && r.split {
|
||||
if vfoDigit == '0' {
|
||||
v = '1'
|
||||
} else {
|
||||
v = '0'
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s%c;", strings.TrimSuffix(cmd, ";"), v)
|
||||
}
|
||||
return "" // AI0;, TX;, RX; — set commands, no reply, as on a real rig
|
||||
}
|
||||
|
||||
func dialTo(rig *ts2000) func() (serial.Port, error) {
|
||||
return func() (serial.Port, error) {
|
||||
return &fakeSerial{toRig: &strings.Builder{}, answer: rig.answer}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestKenwoodAgainstEmulatedRig(t *testing.T) {
|
||||
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2'} // 20 m USB
|
||||
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||
k.dialPort = dialTo(rig)
|
||||
|
||||
if err := k.Connect(); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer k.Disconnect()
|
||||
if k.model != "TS-2000" {
|
||||
t.Errorf("model = %q, want TS-2000 (from ID019)", k.model)
|
||||
}
|
||||
|
||||
// Simplex on A.
|
||||
s, err := k.ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !s.Connected || s.FreqHz != 14250000 || s.Mode != "USB" || s.Vfo != "A" || s.Split {
|
||||
t.Errorf("simplex A gave %+v — want 14250000 USB on A, no split", s)
|
||||
}
|
||||
|
||||
// On B: everything must follow the VFO in use, the fault that took several
|
||||
// rounds to settle in the Yaesu backend.
|
||||
rig.onB = true
|
||||
if s, err = k.ReadState(); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if s.FreqHz != 14260000 || s.Vfo != "B" {
|
||||
t.Errorf("on B gave %d on VFO %s — want 14260000 on B", s.FreqHz, s.Vfo)
|
||||
}
|
||||
|
||||
// Tuning must write to the VFO in use, not blindly to FA.
|
||||
if err := k.SetFrequency(14265000); err != nil {
|
||||
t.Fatalf("set freq: %v", err)
|
||||
}
|
||||
if rig.vfoB != 14265000 {
|
||||
t.Errorf("VFO B = %d, want 14265000 — the write went to the wrong VFO", rig.vfoB)
|
||||
}
|
||||
if rig.vfoA != 14250000 {
|
||||
t.Errorf("VFO A was disturbed: %d", rig.vfoA)
|
||||
}
|
||||
|
||||
// Split: receive on B, transmit on A. ADIF says FREQ is the TRANSMIT
|
||||
// frequency, so the two must not be swapped — a mistake that writes the
|
||||
// wrong frequency into every logged QSO.
|
||||
rig.split = true
|
||||
if s, err = k.ReadState(); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !s.Split || s.FreqHz != 14250000 || s.RxFreqHz != 14265000 {
|
||||
t.Errorf("split gave tx=%d rx=%d split=%v — want tx 14250000 (A), rx 14265000 (B)",
|
||||
s.FreqHz, s.RxFreqHz, s.Split)
|
||||
}
|
||||
|
||||
// Mode: CW below 10 MHz stays CW; the sideband convention only governs SSB.
|
||||
rig.split = false
|
||||
if err := k.SetMode("CW"); err != nil {
|
||||
t.Fatalf("set mode: %v", err)
|
||||
}
|
||||
if rig.mode != '3' {
|
||||
t.Errorf("mode digit = %q, want '3' (CW)", rig.mode)
|
||||
}
|
||||
|
||||
// PTT is a bare command, and the rig must have actually seen it.
|
||||
if err := k.SetPTT(true); err != nil {
|
||||
t.Fatalf("ptt: %v", err)
|
||||
}
|
||||
if err := k.SetPTT(false); err != nil {
|
||||
t.Fatalf("ptt off: %v", err)
|
||||
}
|
||||
seen := strings.Join(rig.seen, " ")
|
||||
for _, want := range []string{"AI0;", "TX;", "RX;"} {
|
||||
if !strings.Contains(seen, want) {
|
||||
t.Errorf("the rig never received %s — sent: %s", want, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A port that opens onto silence must be reported as such, not as a connected
|
||||
// radio. A powered-off rig logged as "connected" wasted an evening of a user's
|
||||
// time on the Yaesu backend.
|
||||
func TestKenwoodSilentRigIsNotConnected(t *testing.T) {
|
||||
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||
k.dialPort = func() (serial.Port, error) {
|
||||
return &fakeSerial{toRig: &strings.Builder{}, answer: func(string) string { return "" }}, nil
|
||||
}
|
||||
err := k.Connect()
|
||||
if err == nil {
|
||||
t.Fatal("a silent port was reported as a connected rig")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "sent nothing") {
|
||||
t.Errorf("error was %q — a silent port should be reported as silence", err)
|
||||
}
|
||||
if k.model != "" {
|
||||
t.Errorf("a stale model survived a failed connect: %q", k.model)
|
||||
}
|
||||
}
|
||||
|
||||
// Split found through FR/FT when the rig never fills IF's split bit.
|
||||
//
|
||||
// Reported on a Flex through its Kenwood CAT emulation: frequency read
|
||||
// perfectly, split never appeared. Rather than guess at which IF column that
|
||||
// firmware populates, ask the question that DEFINES split — is the transmit VFO
|
||||
// a different VFO from the receive one — which is what FR and FT answer, and
|
||||
// the same rule the Yaesu backend settled on after several wrong turns.
|
||||
func TestKenwoodSplitFromFRFT(t *testing.T) {
|
||||
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2', lazyIF: true}
|
||||
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||
k.dialPort = dialTo(rig)
|
||||
if err := k.Connect(); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer k.Disconnect()
|
||||
|
||||
// Simplex: FR and FT agree, and nothing may be invented from that.
|
||||
s, err := k.ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if s.Split {
|
||||
t.Errorf("split reported while FR and FT agree: tx=%d rx=%d", s.FreqHz, s.RxFreqHz)
|
||||
}
|
||||
|
||||
// Split on, IF still silent about it: receive on A, transmit on B.
|
||||
rig.split = true
|
||||
if s, err = k.ReadState(); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !s.Split {
|
||||
t.Fatal("split not detected — FR/FT disagreed and IF's bit was empty, which is the reported case")
|
||||
}
|
||||
if s.FreqHz != 14260000 || s.RxFreqHz != 14250000 {
|
||||
t.Errorf("tx=%d rx=%d — want tx 14260000 (B), rx 14250000 (A)", s.FreqHz, s.RxFreqHz)
|
||||
}
|
||||
}
|
||||
|
||||
// A rig that rejects FR/FT is asked once, then left alone — and its IF split
|
||||
// bit still works. The fallback must not cost a timeout on every poll, nor
|
||||
// break the rigs that were already fine.
|
||||
func TestKenwoodSplitWhenFRFTRejected(t *testing.T) {
|
||||
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2', noVFOCmds: true}
|
||||
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||
k.dialPort = dialTo(rig)
|
||||
if err := k.Connect(); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer k.Disconnect()
|
||||
|
||||
if _, err := k.ReadState(); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
asked := 0
|
||||
for _, c := range rig.seen {
|
||||
if c == "FR;" || c == "FT;" {
|
||||
asked++
|
||||
}
|
||||
}
|
||||
if _, err := k.ReadState(); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
after := 0
|
||||
for _, c := range rig.seen {
|
||||
if c == "FR;" || c == "FT;" {
|
||||
after++
|
||||
}
|
||||
}
|
||||
if after != asked {
|
||||
t.Errorf("a rejected command was asked again: %d then %d", asked, after)
|
||||
}
|
||||
|
||||
// IF's own split bit still drives the result on such a rig.
|
||||
rig.split = true
|
||||
s, err := k.ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !s.Split || s.FreqHz != 14260000 || s.RxFreqHz != 14250000 {
|
||||
t.Errorf("IF-reported split broke: split=%v tx=%d rx=%d", s.Split, s.FreqHz, s.RxFreqHz)
|
||||
}
|
||||
}
|
||||
|
||||
// A rig that talks but never answers what was asked must not be reported as a
|
||||
// silent one.
|
||||
//
|
||||
// The two faults need opposite responses: silence means the radio is off, the
|
||||
// wrong port, or a dead cable; noise means the baud rate is wrong or something
|
||||
// is echoing the line. "The rig is not answering" sent an operator checking the
|
||||
// power switch on a radio whose frames were visibly arriving.
|
||||
func TestKenwoodNoisyRigIsReportedAsNoiseNotSilence(t *testing.T) {
|
||||
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||
k.dialPort = func() (serial.Port, error) {
|
||||
return &fakeSerial{toRig: &strings.Builder{}, answer: func(cmd string) string {
|
||||
return "XX9999;" // something, but never the reply to ID; or IF;
|
||||
}}, nil
|
||||
}
|
||||
err := k.Connect()
|
||||
if err == nil {
|
||||
t.Fatal("a rig answering gibberish was reported as connected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "sending data") || !strings.Contains(err.Error(), "XX9999;") {
|
||||
t.Errorf("error was %q — it should say data arrived, and quote it", err)
|
||||
}
|
||||
}
|
||||
+34
-18
@@ -75,37 +75,53 @@ func DebugLogPath() string {
|
||||
return filepath.Join(base, "OpsLog", "cat.log")
|
||||
}
|
||||
|
||||
// ── CI-V byte trace ────────────────────────────────────────────────────────
|
||||
// ── CAT wire trace ─────────────────────────────────────────
|
||||
//
|
||||
// Opt-in, off by default. Turning it on logs every CI-V frame sent and received
|
||||
// as hex, exactly like the WinKeyer protocol trace.
|
||||
// Opt-in, off by default. Turning it on logs every frame sent and received —
|
||||
// CI-V as hex, the ASCII backends (Kenwood, Yaesu) as the text they exchange.
|
||||
//
|
||||
// That trace exists because a fault nobody could reason about — "the keyer sends
|
||||
// one element then stalls" — was settled in one line the moment the actual bytes
|
||||
// were visible. The CI-V link has now produced the same class of report: a MOX
|
||||
// button that sets split instead of transmitting, on a rig whose PTT command is
|
||||
// demonstrably correct in the source. Guessing at that from the command table
|
||||
// has already cost this project a wrong fix; the bytes will say what the rig was
|
||||
// really asked.
|
||||
var civTrace atomic.Bool
|
||||
// It exists because a fault nobody could reason about — "the keyer sends one
|
||||
// element then stalls" — was settled in one line the moment the actual bytes
|
||||
// were visible, and the CAT links have since produced the same class of report:
|
||||
// a MOX button that sets split instead of transmitting, and a split the Kenwood
|
||||
// backend does not recognise on a rig whose frequency it reads perfectly.
|
||||
//
|
||||
// Both are questions about what the RIG actually said. Guessing at that from a
|
||||
// protocol document has already cost this project a wrong fix that broke the
|
||||
// case which already worked.
|
||||
var catTrace atomic.Bool
|
||||
|
||||
// SetCIVTrace turns the CI-V byte trace on or off.
|
||||
// SetCIVTrace turns the CAT wire trace on or off. Named for the CI-V link it
|
||||
// was written for; it now covers the text backends too.
|
||||
func SetCIVTrace(on bool) {
|
||||
civTrace.Store(on)
|
||||
catTrace.Store(on)
|
||||
if on {
|
||||
debugLog.Printf("civ trace ON — every CI-V frame to and from the rig is logged")
|
||||
debugLog.Printf("wire trace ON — every CAT frame to and from the rig is logged")
|
||||
} else {
|
||||
debugLog.Printf("civ trace OFF")
|
||||
debugLog.Printf("wire trace OFF")
|
||||
}
|
||||
}
|
||||
|
||||
// CIVTraceEnabled reports whether the trace is running (for the settings UI).
|
||||
func CIVTraceEnabled() bool { return civTrace.Load() }
|
||||
func CIVTraceEnabled() bool { return catTrace.Load() }
|
||||
|
||||
// traceCIV logs one frame. dir is "TX" (to the rig) or "RX" (from it).
|
||||
// traceCIV logs one CI-V frame. dir is "TX" (to the rig) or "RX" (from it).
|
||||
func traceCIV(dir string, b []byte) {
|
||||
if !civTrace.Load() || len(b) == 0 {
|
||||
if !catTrace.Load() || len(b) == 0 {
|
||||
return
|
||||
}
|
||||
debugLog.Printf("civ %s % X", dir, b)
|
||||
}
|
||||
|
||||
// traceText logs one ASCII exchange, for the Kenwood and Yaesu backends.
|
||||
//
|
||||
// The text is QUOTED. A truncated or empty reply must be visible as such
|
||||
// rather than vanish into the line: "" and ";" look identical unquoted, and
|
||||
// telling them apart is the whole question when a rig answers a command it
|
||||
// does not really support.
|
||||
func traceText(backend, dir, s string) {
|
||||
if !catTrace.Load() || s == "" {
|
||||
return
|
||||
}
|
||||
debugLog.Printf("%s %s %q", backend, dir, s)
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ type Xiegu struct {
|
||||
// asking every cycle — a Xiegu that has no split must not cost a timeout per
|
||||
// poll, which would slow the whole loop to a crawl.
|
||||
splitSupported bool
|
||||
// pttLine is "", "rts" or "dtr": which hardware line keys the rig, when the
|
||||
// CI-V command does not.
|
||||
pttLine string
|
||||
}
|
||||
|
||||
func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu {
|
||||
@@ -74,6 +77,14 @@ func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu {
|
||||
}
|
||||
}
|
||||
|
||||
// SetPTTLine selects the hardware line that keys this rig: "rts", "dtr", or ""
|
||||
// for the CI-V command. Set before Connect.
|
||||
func (x *Xiegu) SetPTTLine(line string) {
|
||||
x.mu.Lock()
|
||||
x.pttLine = strings.ToLower(strings.TrimSpace(line))
|
||||
x.mu.Unlock()
|
||||
}
|
||||
|
||||
func (x *Xiegu) Name() string { return "xiegu" }
|
||||
|
||||
func (x *Xiegu) Connect() error {
|
||||
@@ -87,6 +98,20 @@ func (x *Xiegu) Connect() error {
|
||||
return fmt.Errorf("xiegu: open %s @ %d baud: %w", x.portName, x.baud, err)
|
||||
}
|
||||
p.SetReadTimeout(200 * time.Millisecond)
|
||||
// Deassert DTR and RTS.
|
||||
//
|
||||
// Windows raises both when a serial port is opened, and a great many
|
||||
// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into
|
||||
// transmit the moment OpsLog connects and STAYS there — Xiegu's own
|
||||
// documentation asks for RTS and DTR low. The same applies to an Icom with
|
||||
// USB SEND mapped to a line (which is why the CI-V backend has done this
|
||||
// from the start) and to any rig keyed by a home-made cable.
|
||||
//
|
||||
// PTT on this backend is a CAT command, so neither line should ever be
|
||||
// asserted here. A station keying by RTS/DTR configures that separately,
|
||||
// on its own port.
|
||||
_ = p.SetDTR(false)
|
||||
_ = p.SetRTS(false)
|
||||
x.port = p
|
||||
x.splitSupported = true
|
||||
|
||||
@@ -177,12 +202,25 @@ func (x *Xiegu) SetMode(mode string) error {
|
||||
return x.send(civ.CmdSetMode, m)
|
||||
}
|
||||
|
||||
// SetPTT keys the transmitter, by CI-V or by a hardware line.
|
||||
//
|
||||
// A G90 does not transmit on the CI-V PTT command. Xiegu's own interfaces key
|
||||
// on RTS or DTR instead — the DE-19 does exactly that — which is why the line
|
||||
// can be selected here. Without it, WSJT-X talking to OpsLog's rigctld server
|
||||
// decoded perfectly and never transmitted: the command left, the radio ignored
|
||||
// it, and nothing in the chain was wrong enough to complain.
|
||||
func (x *Xiegu) SetPTT(on bool) error {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
if x.port == nil {
|
||||
return fmt.Errorf("xiegu: not connected")
|
||||
}
|
||||
switch x.pttLine {
|
||||
case "rts":
|
||||
return x.port.SetRTS(on)
|
||||
case "dtr":
|
||||
return x.port.SetDTR(on)
|
||||
}
|
||||
v := byte(0x00)
|
||||
if on {
|
||||
v = 0x01
|
||||
|
||||
+20
-3
@@ -82,9 +82,12 @@ var yaesuModeToADIF = map[byte]string{
|
||||
}
|
||||
|
||||
type Yaesu struct {
|
||||
portName string
|
||||
baud int
|
||||
digital string // ADIF mode reported for DATA (FT8, RTTY…)
|
||||
// lowerLines deasserts DTR and RTS on connect — see Kenwood.lowerLines for
|
||||
// why neither default is safe for every station.
|
||||
lowerLines bool
|
||||
portName string
|
||||
baud int
|
||||
digital string // ADIF mode reported for DATA (FT8, RTTY…)
|
||||
|
||||
mu sync.Mutex
|
||||
port serial.Port
|
||||
@@ -127,6 +130,14 @@ func NewYaesu(portName string, baud int, digital string) *Yaesu {
|
||||
return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
|
||||
}
|
||||
|
||||
// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
|
||||
// before Connect.
|
||||
func (y *Yaesu) SetLowerLines(v bool) {
|
||||
y.mu.Lock()
|
||||
y.lowerLines = v
|
||||
y.mu.Unlock()
|
||||
}
|
||||
|
||||
func (y *Yaesu) Name() string { return "yaesu" }
|
||||
|
||||
func (y *Yaesu) Connect() error {
|
||||
@@ -150,6 +161,12 @@ func (y *Yaesu) Connect() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
|
||||
}
|
||||
// The modem lines are only touched when the operator asks for it — see the
|
||||
// note on Kenwood.lowerLines. Both defaults break somebody's station.
|
||||
if y.lowerLines {
|
||||
_ = p.SetDTR(false)
|
||||
_ = p.SetRTS(false)
|
||||
}
|
||||
p.SetReadTimeout(300 * time.Millisecond)
|
||||
y.port = p
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ package cluster
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
@@ -346,12 +347,29 @@ func (s *session) run() {
|
||||
}
|
||||
}
|
||||
|
||||
// idleTick is how often the read is interrupted so the loop can notice a stop
|
||||
// request. It is NOT a liveness test — see the read loop.
|
||||
//
|
||||
// A var, not a const, so the test that proves a silent node survives can shorten
|
||||
// it: at 30 s that test spends a minute doing nothing, and a slow test is a test
|
||||
// that gets skipped.
|
||||
var idleTick = 30 * time.Second
|
||||
|
||||
// quietNotice is the silence after which the log says so, once.
|
||||
const quietNotice = 10 * time.Minute
|
||||
|
||||
// runOnce dials, optionally logs in, sends init commands, parses spots.
|
||||
// Returns the moment we marked the link "connected" (zero if dial failed)
|
||||
// and the error that ended the session (nil if stopCh).
|
||||
func (s *session) runOnce() (time.Time, error) {
|
||||
addr := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port)) // IPv6-safe
|
||||
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||
// KeepAlive is set explicitly rather than left to the package default: it is
|
||||
// what actually detects a dead peer here, so it should be visible in the
|
||||
// code that depends on it. The OS probes an idle connection and a genuine
|
||||
// failure surfaces as an error on Read — which is the only thing that ends
|
||||
// a session below.
|
||||
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
conn, err := d.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
@@ -423,6 +441,8 @@ func (s *session) runOnce() (time.Time, error) {
|
||||
}
|
||||
|
||||
var connectedAt time.Time
|
||||
var quiet time.Duration // how long the node has said nothing
|
||||
var pending string // a line cut in half by a read deadline
|
||||
rd := bufio.NewReader(conn)
|
||||
for {
|
||||
select {
|
||||
@@ -431,11 +451,37 @@ func (s *session) runOnce() (time.Time, error) {
|
||||
default:
|
||||
}
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(120 * time.Second))
|
||||
line, err := rd.ReadString('\n')
|
||||
// The deadline exists to keep this loop responsive to stopCh, NOT to
|
||||
// judge the link. A quiet node is a quiet node: some clusters send
|
||||
// nothing for minutes on a dead band, and tearing the socket down over
|
||||
// it produced a reconnect every two minutes — which loses the login,
|
||||
// the filters, and any spot that arrived during the gap.
|
||||
//
|
||||
// Only a REAL error ends the session. A dead peer still gets caught:
|
||||
// TCP keepalive probes an idle connection and its failure arrives here
|
||||
// as an error, not as a timeout.
|
||||
_ = conn.SetReadDeadline(time.Now().Add(idleTick))
|
||||
chunk, err := rd.ReadString('\n')
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
// ReadString hands back what it HAD read along with the timeout.
|
||||
// Keep it: a spot line straddling the deadline would otherwise lose
|
||||
// its first half and arrive as nonsense, or vanish entirely.
|
||||
pending += chunk
|
||||
quiet += idleTick
|
||||
// Said once at the first long silence, so a genuinely mute node is
|
||||
// visible without a line every tick.
|
||||
if quiet == quietNotice {
|
||||
applog.Printf("cluster[%s] no traffic for %s — still connected", s.cfg.Name, quiet)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return connectedAt, fmt.Errorf("read: %w", err)
|
||||
}
|
||||
quiet = 0
|
||||
line := pending + chunk
|
||||
pending = ""
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
// Strip terminal BELLs (\a) and other control bytes that some nodes
|
||||
// (e.g. F5LEN) append to spot lines — "DX de …0935Z KN04\a\a" — which
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A quiet cluster must not be treated as a broken one.
|
||||
//
|
||||
// Reported by an operator whose node sends little: nothing for two minutes and
|
||||
// OpsLog reconnected — losing the login, the filters, and any spot that landed
|
||||
// during the gap. The read deadline was being read as a verdict on the link
|
||||
// rather than as a way to stay responsive to a stop request.
|
||||
//
|
||||
// The test holds the connection open, silent for longer than one deadline, then
|
||||
// sends a spot. A session that survives receives it; one that reconnects does
|
||||
// not, because this listener never accepts twice.
|
||||
func TestSilenceDoesNotEndTheSession(t *testing.T) {
|
||||
// Shorten the tick so the test exercises the real code path in seconds.
|
||||
defer func(orig time.Duration) { idleTick = orig }(idleTick)
|
||||
idleTick = time.Second
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
accepted := make(chan net.Conn, 2)
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
accepted <- c
|
||||
}
|
||||
}()
|
||||
|
||||
host, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
spots := make(chan Spot, 4)
|
||||
s := &session{
|
||||
cfg: ServerConfig{Name: "quiet", Host: host, Port: port},
|
||||
onSpot: func(sp Spot) { spots <- sp },
|
||||
onLine: func(Line) {},
|
||||
onStatus: func() {},
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { _, err := s.runOnce(); done <- err }()
|
||||
|
||||
conn := <-accepted
|
||||
defer conn.Close()
|
||||
|
||||
// Silent for longer than one deadline: the loop must ride through it.
|
||||
time.Sleep(3 * idleTick)
|
||||
|
||||
select {
|
||||
case c := <-accepted:
|
||||
c.Close()
|
||||
t.Fatal("the session reconnected during the silence")
|
||||
case err := <-done:
|
||||
t.Fatalf("the session ended during the silence: %v", err)
|
||||
default:
|
||||
}
|
||||
|
||||
// Now a spot: it proves the ORIGINAL connection is still the live one.
|
||||
if _, err := conn.Write([]byte("DX de F4BPO: 14074.0 OY1CT FT8 1234Z\r\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case sp := <-spots:
|
||||
if sp.DXCall != "OY1CT" {
|
||||
t.Errorf("spot from the wrong station: %+v", sp)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the spot sent after the silence never arrived")
|
||||
}
|
||||
|
||||
close(s.stopCh)
|
||||
<-done
|
||||
}
|
||||
@@ -125,6 +125,10 @@ type Decoder struct {
|
||||
pendHops int // consecutive hops the raw state has disagreed
|
||||
|
||||
// Two-cluster element timing (ms).
|
||||
// muLetterGap is this operator's typical gap BETWEEN LETTERS (ms), so the
|
||||
// word boundary can follow a wide fist instead of chopping up callsigns.
|
||||
// Zero until enough gaps have been seen.
|
||||
muLetterGap float64
|
||||
muDit, muDah float64
|
||||
marksSeen int
|
||||
|
||||
@@ -195,6 +199,19 @@ const (
|
||||
|
||||
charGapDits = 2.2 // gap > this ⇒ character boundary (geom. mean of 1 & 3 ≈ 1.7, plus margin for sloppy fists)
|
||||
wordGapDits = 4.6 // gap > this ⇒ word boundary (geom. mean of 3 & 7)
|
||||
|
||||
// newOverFloorMs is the shortest silence that may end an over — see newOverMs.
|
||||
// Past it the speed estimate is dropped: the next voice on the frequency is
|
||||
// probably somebody else, and the last operator's speed is not evidence
|
||||
// about them. Two seconds is far longer than any word gap (7 dits is 0.42 s
|
||||
// even at 10 wpm) and far shorter than a pause between overs.
|
||||
newOverFloorMs = 600.0
|
||||
|
||||
// wordGapRatio places the word boundary relative to the letter gaps this
|
||||
// operator ACTUALLY sends, for fists whose letter spacing runs wide. The
|
||||
// fixed 4.6-dit rule turned a 5-dit letter gap into a word, so a callsign
|
||||
// arrived as "O Y 1 C T" — which is worse than two words run together.
|
||||
wordGapRatio = 1.6
|
||||
)
|
||||
|
||||
// New builds a decoder for the given sample rate. onChar receives decoded text
|
||||
@@ -260,6 +277,7 @@ func (d *Decoder) Reset() {
|
||||
d.stableHops, d.pendHops = 0, 0
|
||||
d.bankTick, d.betterHops = 0, 0
|
||||
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
|
||||
d.muLetterGap = 0
|
||||
d.elemMs = d.elemMs[:0]
|
||||
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
|
||||
}
|
||||
@@ -578,6 +596,30 @@ func (d *Decoder) endMark(hops int) {
|
||||
// steadier than dits in hand keying).
|
||||
func (d *Decoder) endSpace(hops int) {
|
||||
ms := float64(hops)*d.hopMs + d.biasMs // gaps shrink by what marks gained
|
||||
|
||||
// A long silence ends the over — see newOverMs. Forget the speed: coming back at 14 wpm
|
||||
// after following someone at 32, every element of the newcomer measured
|
||||
// longer than the stale dah threshold and the first words decoded as a run
|
||||
// of T's. Starting from the seed instead, the estimate re-converges within a
|
||||
// character — which is exactly how a freshly started decoder behaves, and
|
||||
// that case was always fine.
|
||||
if d.marksSeen > 0 && ms > d.newOverMs() {
|
||||
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
|
||||
d.muLetterGap = 0
|
||||
d.elemMs = d.elemMs[:0]
|
||||
return
|
||||
}
|
||||
|
||||
// Letter gaps: everything between a character boundary and a word boundary.
|
||||
// Kept so the word boundary can follow this operator's own spacing.
|
||||
if d.marksSeen > 0 && ms > charGapDits*d.muDit && ms < wordGapDits*d.muDit*2 {
|
||||
if d.muLetterGap == 0 {
|
||||
d.muLetterGap = ms
|
||||
} else {
|
||||
d.muLetterGap += (ms - d.muLetterGap) * 0.2
|
||||
}
|
||||
}
|
||||
|
||||
if d.marksSeen < 1 || ms < 0.35*d.muDit || ms > 1.7*d.muDit {
|
||||
return
|
||||
}
|
||||
@@ -592,6 +634,40 @@ func (d *Decoder) endSpace(hops int) {
|
||||
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
|
||||
}
|
||||
|
||||
// newOverMs is the silence above which the speed estimate is dropped.
|
||||
//
|
||||
// It cannot be a fixed duration: 0.75 s is a quick turnaround at 30 wpm but is
|
||||
// barely a word gap at 10 wpm (7 dits = 0.84 s). So it scales with the current
|
||||
// estimate, with a floor so a fast operator pausing to think is not mistaken
|
||||
// for a new station on every hesitation.
|
||||
func (d *Decoder) newOverMs() float64 {
|
||||
if v := 12 * d.muDit; v > newOverFloorMs {
|
||||
return v
|
||||
}
|
||||
return newOverFloorMs
|
||||
}
|
||||
|
||||
// wordGapMs is the silence above which a word boundary is declared.
|
||||
//
|
||||
// The textbook answer is 4.6 dits — the geometric mean of a 3-dit letter gap
|
||||
// and a 7-dit word gap. It assumes the operator sends textbook spacing. Many do
|
||||
// not: a fist that leaves 5 dits between letters had every letter turned into a
|
||||
// word, so callsigns arrived in pieces.
|
||||
//
|
||||
// So the boundary also follows the letter gaps actually observed. Whichever is
|
||||
// larger wins: a textbook fist keeps the textbook boundary, a wide one gets a
|
||||
// wider boundary. The cost is that a wide sender's words may run together —
|
||||
// which is a far smaller price than a callsign broken into letters.
|
||||
func (d *Decoder) wordGapMs() float64 {
|
||||
fixed := wordGapDits * d.muDit
|
||||
if d.muLetterGap > 0 {
|
||||
if adaptive := wordGapRatio * d.muLetterGap; adaptive > fixed {
|
||||
return adaptive
|
||||
}
|
||||
}
|
||||
return fixed
|
||||
}
|
||||
|
||||
// spaceProgress emits the pending character / word space LIVE once the current
|
||||
// gap crosses each boundary (instead of waiting for the next mark), so text
|
||||
// appears as it is sent.
|
||||
@@ -601,7 +677,7 @@ func (d *Decoder) spaceProgress() {
|
||||
d.flushChar()
|
||||
d.charEmitted = true
|
||||
}
|
||||
if !d.wordEmitted && gapMs > wordGapDits*d.muDit {
|
||||
if !d.wordEmitted && gapMs > d.wordGapMs() {
|
||||
if d.textSince && d.onChar != nil {
|
||||
d.onChar(" ")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package cwdecode
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// keyLoose keys a message with SLIGHTLY WIDE letter spacing — the way a great
|
||||
// many operators actually send. letterGapDits replaces the standard 3.
|
||||
//
|
||||
// Same envelope shaping as keyMessage: hard edges would make an easier signal
|
||||
// than anything on the air, and would prove nothing.
|
||||
func keyLoose(msg string, fs, wpm int, pitch, amp float64, letterGapDits float64) []int16 {
|
||||
dot := fs * 1200 / (wpm * 1000)
|
||||
edge := fs * 5 / 1000
|
||||
c2m := charToMorse()
|
||||
var out []float64
|
||||
phase := 0.0
|
||||
dphi := 2 * math.Pi * pitch / float64(fs)
|
||||
|
||||
tone := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
g := 1.0
|
||||
if i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(i)/float64(edge))
|
||||
} else if n-1-i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(n-1-i)/float64(edge))
|
||||
}
|
||||
out = append(out, amp*g*math.Sin(phase))
|
||||
phase += dphi
|
||||
}
|
||||
}
|
||||
silence := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, 0)
|
||||
phase += dphi
|
||||
}
|
||||
}
|
||||
|
||||
silence(fs / 4)
|
||||
for i := 0; i < len(msg); i++ {
|
||||
ch := msg[i]
|
||||
if ch == ' ' {
|
||||
silence(4 * dot)
|
||||
continue
|
||||
}
|
||||
code := c2m[ch]
|
||||
for j := 0; j < len(code); j++ {
|
||||
if code[j] == '.' {
|
||||
tone(dot)
|
||||
} else {
|
||||
tone(3 * dot)
|
||||
}
|
||||
silence(dot)
|
||||
}
|
||||
// The element gap above already contributes one dit.
|
||||
silence(int((letterGapDits - 1) * float64(dot)))
|
||||
}
|
||||
silence(fs / 2)
|
||||
return toInt16(out)
|
||||
}
|
||||
|
||||
// The first character of an over must decode, not arrive as "?".
|
||||
//
|
||||
// Reported on the air: "the first letter or digit often turns into ?". Each
|
||||
// element is classified against the running dit estimate, and at the start of
|
||||
// an over that estimate belongs to whatever was decoded last — a different
|
||||
// operator, at a different speed. The first character pays for it.
|
||||
func TestFirstCharacterOfAnOver(t *testing.T) {
|
||||
const fs = 16000
|
||||
for _, wpm := range []int{15, 22, 30} {
|
||||
got := decode(t, keyMessage("F4BPO DE OY1CT K", fs, wpm, 700, 9000), 0)
|
||||
if strings.HasPrefix(got, "?") {
|
||||
t.Errorf("@%d wpm: decoded %q — the first character was lost", wpm, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A callsign must not be broken up when the sender's letter spacing is a little
|
||||
// wide.
|
||||
//
|
||||
// Reported on the air: "sometimes there are spaces inside the call". A word
|
||||
// boundary is declared above 4.6 dits of silence, so an operator whose letter
|
||||
// gaps run to 4.5 dits has every letter turned into a word of its own.
|
||||
func TestWideLetterSpacingDoesNotSplitTheCall(t *testing.T) {
|
||||
const fs = 16000
|
||||
for _, gap := range []float64{3.5, 4.0, 4.5} {
|
||||
got := decode(t, keyLoose("DE OY1CT K", fs, 20, 700, 9000, gap), 0)
|
||||
for _, split := range []string{"O Y", "Y 1", "1 C", "C T"} {
|
||||
if strings.Contains(got, split) {
|
||||
t.Errorf("letter gap %.1f dits: decoded %q — the callsign was broken at %q", gap, got, split)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decodeSeq runs several transmissions through the SAME decoder, as happens on
|
||||
// the air: the estimate carried into an over is whatever the previous station
|
||||
// left behind.
|
||||
func decodeSeq(t *testing.T, parts ...[]int16) []string {
|
||||
t.Helper()
|
||||
var cur strings.Builder
|
||||
d := New(16000, func(s string) { cur.WriteString(s) }, nil)
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
cur.Reset()
|
||||
for i := 0; i < len(p); i += 256 {
|
||||
end := i + 256
|
||||
if end > len(p) {
|
||||
end = len(p)
|
||||
}
|
||||
d.Process(p[i:end])
|
||||
}
|
||||
out = append(out, strings.ToUpper(cur.String()))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A fast station, then a slow one — the real reason the first character is
|
||||
// lost. A fresh decoder starts from a neutral estimate and adapts within a
|
||||
// character or two; a decoder that has just followed someone at 30 wpm judges
|
||||
// the newcomer's first dits against 30 wpm.
|
||||
func TestFirstCharacterAfterASpeedChange(t *testing.T) {
|
||||
const fs = 16000
|
||||
got := decodeSeq(t,
|
||||
keyMessage("CQ CQ DE DL1ABC K", fs, 32, 700, 9000),
|
||||
keyMessage("DL1ABC DE OY1CT K", fs, 14, 700, 9000),
|
||||
)
|
||||
if strings.HasPrefix(got[1], "?") {
|
||||
t.Errorf("after 32→14 wpm: %q — the first character of the reply was lost", got[1])
|
||||
}
|
||||
if !strings.Contains(got[1], "OY1CT") {
|
||||
t.Errorf("after 32→14 wpm: %q — want the callsign intact", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Hand-sent letter gaps run wide. Above 4.6 dits every letter becomes a word,
|
||||
// so a callsign arrives in pieces.
|
||||
func TestVeryWideLetterSpacing(t *testing.T) {
|
||||
const fs = 16000
|
||||
for _, gap := range []float64{5.0, 5.5, 6.0} {
|
||||
got := decode(t, keyLoose("DE OY1CT K", fs, 18, 700, 9000, gap), 0)
|
||||
if strings.Contains(got, "O Y") || strings.Contains(got, "Y 1") || strings.Contains(got, "1 C") {
|
||||
t.Errorf("letter gap %.1f dits: decoded %q — the callsign was broken up", gap, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package extsvc
|
||||
|
||||
// Withdrawing a QSO from the external services after it is deleted locally.
|
||||
//
|
||||
// The two services could hardly be less alike, and the difference decides what
|
||||
// is possible for QSOs already in the log:
|
||||
//
|
||||
// - QRZ.com deletes ONLY by logid — the identifier it returns on INSERT.
|
||||
// There is no delete-by-callsign-and-date. A QSO uploaded before OpsLog
|
||||
// started recording that identifier therefore cannot be withdrawn from
|
||||
// here; the operator has to do it on the website. QRZ's own documentation
|
||||
// puts it plainly: "There is no undo from this operation."
|
||||
// - Club Log deletes by MATCH: the worked callsign, the exact timestamp and
|
||||
// the band. Nothing has to have been stored in advance, so this works for
|
||||
// every QSO in the log, past or future.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// clublogDeleteURL is Club Log's real-time single-QSO delete endpoint.
|
||||
const clublogDeleteURL = "https://clublog.org/delete.php"
|
||||
|
||||
// DeleteQRZ removes QSOs from a QRZ logbook by their logid.
|
||||
//
|
||||
// The identifiers come from the LOGID QRZ returns on INSERT, which OpsLog
|
||||
// stores on the QSO as APP_OPSLOG_QRZ_LOGID.
|
||||
//
|
||||
// QRZ answers RESULT=OK when every id was deleted, PARTIAL when some were not
|
||||
// found, FAIL when none were. PARTIAL and FAIL are NOT treated as errors here:
|
||||
// the QSO is being deleted locally either way, and "it was already gone from
|
||||
// QRZ" is the outcome the operator wanted. The message is returned so the
|
||||
// caller can log what actually happened.
|
||||
func DeleteQRZ(ctx context.Context, client *http.Client, apiKey string, logIDs []string) (string, error) {
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if apiKey == "" {
|
||||
return "", fmt.Errorf("qrz: api key not set")
|
||||
}
|
||||
ids := make([]string, 0, len(logIDs))
|
||||
for _, id := range logIDs {
|
||||
if id = strings.TrimSpace(id); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return "", fmt.Errorf("qrz: no logid recorded for this QSO — it can only be removed on qrz.com")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("KEY", apiKey)
|
||||
form.Set("ACTION", "DELETE")
|
||||
form.Set("LOGIDS", strings.Join(ids, ","))
|
||||
|
||||
body, err := postForm(ctx, client, qrzAPIURL, form, 20*time.Second)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("qrz: %w", err)
|
||||
}
|
||||
LogSink("qrz: DELETE raw response: %s", strings.TrimSpace(body))
|
||||
|
||||
vals, _ := url.ParseQuery(strings.TrimSpace(body))
|
||||
result := strings.ToUpper(strings.TrimSpace(vals.Get("RESULT")))
|
||||
count := strings.TrimSpace(vals.Get("COUNT"))
|
||||
switch result {
|
||||
case "OK", "PARTIAL", "FAIL":
|
||||
return fmt.Sprintf("RESULT=%s COUNT=%s", result, count), nil
|
||||
case "AUTH":
|
||||
return "", fmt.Errorf("qrz: the logbook key was refused")
|
||||
}
|
||||
return "", fmt.Errorf("qrz: unexpected reply %q", strings.TrimSpace(body))
|
||||
}
|
||||
|
||||
// DeleteClublog removes one QSO from a Club Log logbook.
|
||||
//
|
||||
// whenUTC must be the QSO's start time; Club Log matches on it EXACTLY, to the
|
||||
// second, so a rounded or local-time value simply finds nothing. band is an
|
||||
// ADIF band name ("20m"), translated below to the band number Club Log wants.
|
||||
func DeleteClublog(ctx context.Context, client *http.Client, cfg ServiceConfig, dxCall string, whenUTC time.Time, band string) (string, error) {
|
||||
email := strings.TrimSpace(cfg.Email)
|
||||
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
|
||||
dx := strings.ToUpper(strings.TrimSpace(dxCall))
|
||||
switch {
|
||||
case email == "":
|
||||
return "", fmt.Errorf("clublog: account email not set")
|
||||
case cfg.Password == "":
|
||||
return "", fmt.Errorf("clublog: password not set")
|
||||
case call == "":
|
||||
return "", fmt.Errorf("clublog: logbook callsign not set")
|
||||
case dx == "":
|
||||
return "", fmt.Errorf("clublog: no callsign to delete")
|
||||
case whenUTC.IsZero():
|
||||
return "", fmt.Errorf("clublog: the QSO has no time — Club Log matches on it exactly")
|
||||
}
|
||||
bandID, ok := clublogBandID(band)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("clublog: band %q is not one Club Log accepts for deletion", band)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("email", email)
|
||||
form.Set("password", cfg.Password)
|
||||
form.Set("callsign", call)
|
||||
form.Set("dxcall", dx)
|
||||
form.Set("datetime", whenUTC.UTC().Format("2006-01-02 15:04:05"))
|
||||
form.Set("bandid", strconv.Itoa(bandID))
|
||||
api := strings.TrimSpace(cfg.APIKey)
|
||||
if api == "" {
|
||||
api = clublogAppAPIKey
|
||||
}
|
||||
form.Set("api", api)
|
||||
|
||||
body, err := postForm(ctx, client, clublogDeleteURL, form, 20*time.Second)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("clublog: %w", err)
|
||||
}
|
||||
msg := strings.TrimSpace(body)
|
||||
LogSink("clublog: DELETE raw response: %s", msg)
|
||||
// "QSO Not Deleted" means no record matched. That is not an error worth
|
||||
// stopping for — the QSO is going away locally regardless, and a QSO absent
|
||||
// from Club Log is the state being asked for.
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// clublogBandID maps an ADIF band name to Club Log's band number.
|
||||
//
|
||||
// The list is Club Log's own, and it is deliberately NOT derived from the
|
||||
// frequency: Club Log accepts these values and no others, so a band outside it
|
||||
// must be reported rather than approximated to a neighbour.
|
||||
func clublogBandID(band string) (int, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(band)) {
|
||||
case "160m":
|
||||
return 160, true
|
||||
case "80m":
|
||||
return 80, true
|
||||
case "60m":
|
||||
return 60, true
|
||||
case "40m":
|
||||
return 40, true
|
||||
case "30m":
|
||||
return 30, true
|
||||
case "20m":
|
||||
return 20, true
|
||||
case "17m":
|
||||
return 17, true
|
||||
case "15m":
|
||||
return 15, true
|
||||
case "12m":
|
||||
return 12, true
|
||||
case "10m":
|
||||
return 10, true
|
||||
case "6m":
|
||||
return 6, true
|
||||
case "4m":
|
||||
return 4, true
|
||||
case "2m":
|
||||
return 2, true
|
||||
case "70cm":
|
||||
return 70, true
|
||||
case "23cm":
|
||||
return 23, true
|
||||
case "13cm":
|
||||
return 13, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// postForm is the shared request/read for both deletes.
|
||||
func postForm(ctx context.Context, client *http.Client, endpoint string, form url.Values, timeout time.Duration) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", clublogUserAgent)
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: timeout}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
b := string(raw)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(b))
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package extsvc
|
||||
|
||||
import "testing"
|
||||
|
||||
// Club Log matches a deletion on callsign, exact time and BAND NUMBER. A band
|
||||
// that maps to the wrong number does not fail — it points the delete at a
|
||||
// different QSO, or at nothing, silently. So the table is pinned.
|
||||
func TestClublogBandID(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
band string
|
||||
want int
|
||||
}{
|
||||
{"160m", 160}, {"80m", 80}, {"60m", 60}, {"40m", 40}, {"30m", 30},
|
||||
{"20m", 20}, {"17m", 17}, {"15m", 15}, {"12m", 12}, {"10m", 10},
|
||||
{"6m", 6}, {"4m", 4}, {"2m", 2},
|
||||
{"70cm", 70}, {"23cm", 23}, {"13cm", 13},
|
||||
{"20M", 20}, {" 20m ", 20}, // ADIF case and stray spaces
|
||||
} {
|
||||
got, ok := clublogBandID(c.band)
|
||||
if !ok || got != c.want {
|
||||
t.Errorf("clublogBandID(%q) = %d,%v — want %d", c.band, got, ok, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Bands Club Log does not accept must be REFUSED, never approximated to a
|
||||
// neighbour: 6 cm silently becoming 13 cm would delete somebody else's QSO.
|
||||
for _, bad := range []string{"", "6cm", "3cm", "2200m", "630m", "9cm", "banana"} {
|
||||
if id, ok := clublogBandID(bad); ok {
|
||||
t.Errorf("clublogBandID(%q) accepted as %d — it is not a Club Log band", bad, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,13 @@ type ExternalServices struct {
|
||||
HRDLog ServiceConfig `json:"hrdlog"`
|
||||
EQSL ServiceConfig `json:"eqsl"`
|
||||
Cloudlog ServiceConfig `json:"cloudlog"`
|
||||
|
||||
// DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when
|
||||
// it is deleted locally. Off unless the operator turns it on: neither
|
||||
// service can undo it, and a local delete is not always meant to reach the
|
||||
// world — a duplicate cleared from a contest log was never meant to be a
|
||||
// statement about the DX's log.
|
||||
DeleteRemote bool `json:"delete_remote"`
|
||||
}
|
||||
|
||||
// UploadResult is the outcome of a single upload attempt.
|
||||
|
||||
@@ -196,7 +196,6 @@ func (s *Server) run() {
|
||||
}
|
||||
|
||||
func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
applog.Printf("udp: [%s] rx %d bytes from %s\n", s.cfg.Name, len(pkt), remote)
|
||||
ev := Event{ConfigID: s.cfg.ID, Service: s.cfg.ServiceType, Source: remote.String()}
|
||||
switch s.cfg.ServiceType {
|
||||
case ServiceWSJT:
|
||||
@@ -229,8 +228,12 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
ev.Mode = w.Mode
|
||||
break
|
||||
}
|
||||
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
||||
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
||||
// Only a logged QSO is worth a line — WSJT-X/MSHV send a Status packet
|
||||
// every second, and logging each one buried the rest of the file.
|
||||
if len(w.LoggedADIF) > 0 {
|
||||
applog.Printf("udp: [%s] WSJT QSO logged: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
||||
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
||||
}
|
||||
ev.DXCall = w.DXCall
|
||||
ev.DXGrid = w.DXGrid
|
||||
ev.Mode = w.Mode
|
||||
|
||||
@@ -9,6 +9,7 @@ package powergenius
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -36,6 +37,16 @@ type Status struct {
|
||||
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
|
||||
Temperature float64 `json:"temperature"`
|
||||
Operate bool `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it)
|
||||
|
||||
// Live power, read straight from the amplifier's own status frame rather
|
||||
// than sampled off the FlexRadio meter stream. PeakW is the amp's own peak
|
||||
// detector: a poll catches one instant of the envelope, so the plain forward
|
||||
// figure lands between syllables as often as on a peak.
|
||||
FwdW float64 `json:"fwd_w"` // forward power [W] (the frame reports dBm)
|
||||
PeakW float64 `json:"peak_w"` // peak forward power [W]
|
||||
Vswr float64 `json:"vswr"` // VSWR, derived from the frame's return loss in dB
|
||||
Id float64 `json:"id"` // drain current [A]
|
||||
PeakId float64 `json:"peak_id"` // peak drain current [A]
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
@@ -226,9 +237,9 @@ func (c *Client) parse(resp string) {
|
||||
c.statusMu.Lock()
|
||||
c.status.Connected = true
|
||||
c.status.LastError = ""
|
||||
// Log each DISTINCT status payload once: the PGXL's field set isn't fully
|
||||
// documented, so this is how we learn the real key for e.g. operate/standby.
|
||||
if data != c.lastRaw {
|
||||
// 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 == "" {
|
||||
c.lastRaw = data
|
||||
applog.Printf("pgxl: status raw=%q", data)
|
||||
}
|
||||
@@ -252,7 +263,50 @@ func (c *Client) parse(resp string) {
|
||||
c.status.FanMode = dev
|
||||
case "temp":
|
||||
c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64)
|
||||
case "fwd":
|
||||
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
|
||||
c.status.FwdW = dbmToWatts(v)
|
||||
}
|
||||
case "peakfwd":
|
||||
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
|
||||
c.status.PeakW = dbmToWatts(v)
|
||||
}
|
||||
case "swr":
|
||||
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
|
||||
c.status.Vswr = returnLossToVswr(v)
|
||||
}
|
||||
case "id":
|
||||
c.status.Id, _ = strconv.ParseFloat(kv[1], 64)
|
||||
case "peakid":
|
||||
c.status.PeakId, _ = strconv.ParseFloat(kv[1], 64)
|
||||
}
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
|
||||
// dbmToWatts converts a power reading in dBm to watts (0 dBm = 1 mW). The amp
|
||||
// reports power that way — "fwd=60.5" is 1122 W, not 60 W.
|
||||
func dbmToWatts(dbm float64) float64 {
|
||||
if dbm <= 0 {
|
||||
return 0
|
||||
}
|
||||
return math.Pow(10, (dbm-30)/10)
|
||||
}
|
||||
|
||||
// returnLossToVswr converts the amp's "swr" field — a return loss in dB, sent
|
||||
// negative for a good match — into the VSWR ratio an operator reads.
|
||||
func returnLossToVswr(swrDb float64) float64 {
|
||||
rl := math.Abs(swrDb)
|
||||
if rl <= 0 {
|
||||
return 0
|
||||
}
|
||||
rho := math.Pow(10, -rl/20)
|
||||
if rho >= 1 {
|
||||
return 0
|
||||
}
|
||||
vswr := (1 + rho) / (1 - rho)
|
||||
if vswr > 99.9 || math.IsInf(vswr, 0) || math.IsNaN(vswr) {
|
||||
return 0
|
||||
}
|
||||
return vswr
|
||||
}
|
||||
|
||||
@@ -2061,6 +2061,41 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountyWorked reports whether one US county has already been worked.
|
||||
//
|
||||
// It exists so the entry panel can flag a new county without loading the whole
|
||||
// worked-county set on every keystroke — the county arrives from a QRZ lookup,
|
||||
// which is exactly when the operator is deciding whether the contact is worth
|
||||
// chasing. The comparison goes through keyFn on both sides (rather than a SQL
|
||||
// equality) because logged county spellings vary — "Los Angeles" against
|
||||
// "LOS ANGELES, CA" — and the key function is what already resolves that
|
||||
// everywhere else.
|
||||
func (r *Repo) CountyWorked(ctx context.Context, state, cnty string, keyFn func(state, cnty string) string) (bool, error) {
|
||||
want := keyFn(state, cnty)
|
||||
if want == "" {
|
||||
return false, nil
|
||||
}
|
||||
// Narrowed to the one state: a few dozen rows, not the whole logbook.
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT COALESCE(state,''), COALESCE(cnty,'') FROM qso
|
||||
WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''
|
||||
AND upper(COALESCE(state,'')) = upper(?)`, strings.TrimSpace(state))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var st, cn string
|
||||
if err := rows.Scan(&st, &cn); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if keyFn(st, cn) == want {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
// WorkedCallBandModeKeys returns the set of every worked "CALL|BAND|MODE" key
|
||||
// (all upper-cased), loaded in one pass. It backs the in-memory worked-index the
|
||||
// alert engine checks per cluster spot — a DB query per spot cannot keep up with
|
||||
@@ -2506,6 +2541,23 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearQRZConfirmed takes back a QRZ confirmation.
|
||||
//
|
||||
// Needed because OpsLog set some wrongly: it read qrzcom_qso_download_status,
|
||||
// which QRZ puts on everything it hands back, as QRZ's confirmation. Those
|
||||
// QSOs count towards award slots, so leaving them green until the operator
|
||||
// notices is not an option — and nothing else in the app would ever undo them.
|
||||
func (r *Repo) ClearQRZConfirmed(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET qrzcom_qso_download_status = 'N', qrzcom_qso_download_date = NULL,
|
||||
updated_at = ? WHERE id = ?`,
|
||||
db.NowISO(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear qrz confirmed %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkEQSLConfirmed stamps EQSL_QSL_RCVD=Y and the received date on a QSO after
|
||||
// an eQSL Inbox download. date is an ADIF YYYYMMDD string.
|
||||
func (r *Repo) MarkEQSLConfirmed(ctx context.Context, id int64, date string) error {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+129
-26
@@ -33,13 +33,11 @@ const (
|
||||
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE (confirmed on hw)
|
||||
cmdStatus byte = 0x90 // request the status string
|
||||
|
||||
// Best-guess keystroke codes reconstructed from the APG command table after
|
||||
// correcting the PDF's note-wrap misalignment (anchored to the confirmed
|
||||
// OPERATE=0x0D): OFF=0x0A, POWER=0x0B. The POWER key doubles as power-on (when
|
||||
// the amp is off) and the output-level cycle L→M→H (when it's on) — same as the
|
||||
// single physical POWER button. To be verified on hardware.
|
||||
cmdOff byte = 0x0A // OFF key — switch the amplifier off
|
||||
cmdPower byte = 0x0B // POWER key — turn on / cycle output power level
|
||||
// From the official APG rev 1.1 command table. Note POWER does NOT switch a
|
||||
// sleeping amp on (that is the RTS/DTR wake, see PowerOn) — on a running amp
|
||||
// it cycles the output level L→M→H, which is all we use it for.
|
||||
cmdOff byte = 0x0A // SWITCH OFF key — how PowerOff switches the amp off
|
||||
cmdPower byte = 0x0B // POWER key — cycles the output power level
|
||||
|
||||
syncHost = 0x55
|
||||
syncAmp = 0xAA
|
||||
@@ -47,11 +45,16 @@ const (
|
||||
dialTimeout = 5 * time.Second
|
||||
ioTimeout = 3 * time.Second
|
||||
pollEvery = 800 * time.Millisecond
|
||||
|
||||
// How long a modem line is held LOW before being raised again, to give the
|
||||
// amp's edge-triggered remote-on input a transition it can see.
|
||||
wakePulse = time.Second
|
||||
)
|
||||
|
||||
// Status is the decoded amplifier state for the UI.
|
||||
type Status struct {
|
||||
Connected bool `json:"connected"`
|
||||
Transport string `json:"transport"` // "serial" | "tcp" — the UI enables power-ON on serial even when asleep
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Model string `json:"model,omitempty"` // "20K" / "13K"
|
||||
Operate bool `json:"operate"` // true = OPERATE, false = STANDBY
|
||||
@@ -91,6 +94,8 @@ type Client struct {
|
||||
|
||||
stop chan struct{}
|
||||
running bool
|
||||
|
||||
lastConnErr string // last connect failure logged (log only on change)
|
||||
}
|
||||
|
||||
func New(cfg Config) *Client {
|
||||
@@ -123,7 +128,13 @@ func (c *Client) Stop() {
|
||||
func (c *Client) GetStatus() Status {
|
||||
c.statusMu.RLock()
|
||||
defer c.statusMu.RUnlock()
|
||||
return c.status
|
||||
s := c.status
|
||||
if c.cfg.Transport == "tcp" {
|
||||
s.Transport = "tcp"
|
||||
} else {
|
||||
s.Transport = "serial"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (c *Client) setErr(err error) {
|
||||
@@ -146,27 +157,79 @@ func (c *Client) Operate(on bool) error {
|
||||
// ToggleOperate flips STANDBY/OPERATE unconditionally.
|
||||
func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) }
|
||||
|
||||
// PowerOn turns the amplifier on. Per the SPE manual this is NOT a data command
|
||||
// but a hardware DTR pulse on the serial line (the "Remote_ON" pin), which needs
|
||||
// the special SPE cable — so it only applies to the serial transport. Over TCP
|
||||
// there is no DTR line and power-on isn't possible remotely.
|
||||
// PowerOn switches the amplifier on with a RISING EDGE on RTS, then on DTR.
|
||||
//
|
||||
// Confirmed on a live 1.3K-FA. The remote-on input is EDGE-triggered, which is
|
||||
// why holding the lines high does nothing: a plain port open already leaves both
|
||||
// high (the serial library's default), so there is no transition left to give.
|
||||
// Hence low → pause → high on each line, in that order. The keystroke route is a
|
||||
// dead end — a switched-off amp does not answer its UART at all, and the POWER
|
||||
// key (0x0B) merely cycles L/M/H on an amp that is already running.
|
||||
//
|
||||
// Serial only: over TCP there are no modem lines. The port is (re)opened first,
|
||||
// since the poll loop has dropped the connection to a sleeping amp long before
|
||||
// the user clicks ON.
|
||||
func (c *Client) PowerOn() error {
|
||||
if c.cfg.Transport == "tcp" {
|
||||
return fmt.Errorf("power-on needs the serial RTS/DTR lines; not available over a network bridge")
|
||||
}
|
||||
// On an amp that is already awake there is nothing to wake.
|
||||
if c.GetStatus().Connected {
|
||||
return nil
|
||||
}
|
||||
// The poll goroutine may still be inside a blocking read on the old handle;
|
||||
// Windows keeps the port "busy" until that read times out (ioTimeout). Retry
|
||||
// the open for a little longer than that.
|
||||
var err error
|
||||
deadline := time.Now().Add(ioTimeout + 3*time.Second)
|
||||
for {
|
||||
if err = c.ensureConn(); err == nil || time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
applog.Printf("spe: power ON failed — cannot open %s: %v", c.cfg.ComPort, err)
|
||||
return fmt.Errorf("power-on: %w", err)
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
sp, ok := c.conn.(serial.Port)
|
||||
c.mu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("power-on needs the serial DTR line (special SPE cable); not available over network")
|
||||
return fmt.Errorf("power-on needs a serial connection")
|
||||
}
|
||||
// A single positive pulse (~1.2s) on DTR, as the manual describes.
|
||||
if err := sp.SetDTR(true); err != nil {
|
||||
return err
|
||||
pulse := func(set func(bool) error) error {
|
||||
if err := set(false); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(wakePulse)
|
||||
return set(true)
|
||||
}
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
return sp.SetDTR(false)
|
||||
if err = pulse(sp.SetRTS); err == nil {
|
||||
err = pulse(sp.SetDTR)
|
||||
}
|
||||
// Booting, the amp re-enumerates its USB interface, which leaves this handle
|
||||
// pointing at a device that no longer exists — the amp came up and OpsLog
|
||||
// still read "offline". Drop it; the poll loop reopens a fresh one as soon as
|
||||
// the port is back.
|
||||
c.mu.Lock()
|
||||
c.dropLocked()
|
||||
c.mu.Unlock()
|
||||
applog.Printf("spe: power ON — RTS then DTR pulsed on %s (err=%v)", c.cfg.ComPort, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// PowerOff presses the OFF key (switches the amp off).
|
||||
func (c *Client) PowerOff() error { return c.sendCmd(cmdOff) }
|
||||
// PowerOff presses the SWITCH OFF key (0x0A) — the amp is running, so it hears
|
||||
// its UART. Dropping DTR then RTS switches it off too (it is the mirror of the
|
||||
// wake, and does work when done by hand), but back-to-back from one click it
|
||||
// did not, while this keystroke has never once failed. The amp then stays off:
|
||||
// the poll loop keeps reopening the port with both lines high and that has
|
||||
// never woken it — only the deliberate low→high sequence in PowerOn does.
|
||||
func (c *Client) PowerOff() error {
|
||||
err := c.sendCmd(cmdOff)
|
||||
applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetPowerLevel cycles the output power level (L/M/H) to the requested one by
|
||||
// tapping the POWER key (0x0B) and WAITING for the amp to actually report the new
|
||||
@@ -206,9 +269,16 @@ func (c *Client) pollLoop() {
|
||||
return
|
||||
case <-t.C:
|
||||
if err := c.ensureConn(); err != nil {
|
||||
// Logged once per distinct message: "the amp is on but OpsLog
|
||||
// says offline" was impossible to diagnose without the reason.
|
||||
if msg := err.Error(); msg != c.lastConnErr {
|
||||
c.lastConnErr = msg
|
||||
applog.Printf("spe: connect %s failed: %s", c.cfg.ComPort, msg)
|
||||
}
|
||||
c.setErr(fmt.Errorf("connect: %w", err))
|
||||
continue
|
||||
}
|
||||
c.lastConnErr = ""
|
||||
// The amp streams status frames faster than one per poll, so a backlog
|
||||
// builds up and we'd read stale frames (the display lagged ~15s behind
|
||||
// the real amp). Flush anything pending, THEN request + read exactly one
|
||||
@@ -239,16 +309,48 @@ func (c *Client) ensureConn() error {
|
||||
nc, err = net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
|
||||
rwc = nc
|
||||
} else {
|
||||
rwc, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
|
||||
var sp serial.Port
|
||||
// Plain open: both modem lines come up high (the library's default), which
|
||||
// is what the interface needs to talk. That is not enough to switch the amp
|
||||
// on — the remote-on input wants the deliberate low→high sequence PowerOn
|
||||
// sends — so reconnecting never wakes an amplifier the operator switched
|
||||
// off, and forcing either line low here would switch a running one off.
|
||||
sp, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
|
||||
if err == nil {
|
||||
// Without this, a read on a sleeping amp blocks forever inside the
|
||||
// Windows serial driver — the poll goroutine survived the window and
|
||||
// the process took ~a minute to die.
|
||||
_ = sp.SetReadTimeout(ioTimeout)
|
||||
rwc = sp
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.conn = rwc
|
||||
c.r = bufio.NewReader(rwc)
|
||||
if sp, ok := rwc.(serial.Port); ok {
|
||||
// The driver reports a read timeout as "0 bytes, no error", which bufio
|
||||
// spins on; surface it as a real error so the poll loop drops and retries.
|
||||
c.r = bufio.NewReader(serialTimeoutReader{sp})
|
||||
} else {
|
||||
c.r = bufio.NewReader(rwc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// serialTimeoutReader converts the serial driver's timeout convention (n=0,
|
||||
// err=nil once SetReadTimeout expires) into an explicit error, so a bufio
|
||||
// reader fails fast instead of retrying an amp that is asleep.
|
||||
type serialTimeoutReader struct{ p serial.Port }
|
||||
|
||||
func (s serialTimeoutReader) Read(b []byte) (int, error) {
|
||||
n, err := s.p.Read(b)
|
||||
if n == 0 && err == nil {
|
||||
return 0, fmt.Errorf("spe: read timeout")
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *Client) dropLocked() {
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
@@ -361,9 +463,10 @@ func (c *Client) decodeCSV(payload string) {
|
||||
|
||||
c.statusMu.Lock()
|
||||
defer c.statusMu.Unlock()
|
||||
// Log the raw status frame ONCE per change, so field alignment can be verified
|
||||
// against real hardware (the doc's 19-field layout may differ per firmware).
|
||||
if payload != c.lastRaw {
|
||||
// One raw frame per session is enough to verify field alignment against real
|
||||
// hardware — the frame carries live meter values, so logging each change
|
||||
// meant logging nearly every frame.
|
||||
if c.lastRaw == "" {
|
||||
c.lastRaw = payload
|
||||
applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ type Status struct {
|
||||
|
||||
FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported
|
||||
FwdW float64 `json:"fwd_w"` // forward power [W], derived from dBm
|
||||
PeakW float64 `json:"peak_w"` // the device's own peak forward power [W] — what the meters show
|
||||
SwrDb float64 `json:"swr_db"` // return loss [dB] as reported (negative = good match)
|
||||
Vswr float64 `json:"vswr"` // VSWR ratio, derived from swr_db (1.0 = perfect)
|
||||
|
||||
@@ -405,7 +406,9 @@ func (c *Client) parse(resp string) {
|
||||
if !strings.HasPrefix(data, "status") {
|
||||
return
|
||||
}
|
||||
if data != c.lastRaw {
|
||||
// One raw frame per session is enough to verify field alignment — the
|
||||
// frames carry live meter values, so "log on change" logged every frame.
|
||||
if c.lastRaw == "" {
|
||||
c.lastRaw = data
|
||||
applog.Printf("tunergenius: status raw=%q", data)
|
||||
}
|
||||
@@ -439,6 +442,13 @@ func (c *Client) applyStatus(data string) {
|
||||
c.status.FwdDbm = v
|
||||
c.status.FwdW = dbmToWatts(v)
|
||||
}
|
||||
// The device's OWN peak detector. A poll samples one instant of the envelope,
|
||||
// so on SSB the plain "fwd" figure lands between syllables as often as on a
|
||||
// peak — the widget read a few hundred watts on a kilowatt transmission. This
|
||||
// is the number to display.
|
||||
if v, ok := parseFloat(kv["peak"]); ok {
|
||||
c.status.PeakW = dbmToWatts(v)
|
||||
}
|
||||
if v, ok := parseFloat(kv["swr"]); ok {
|
||||
c.status.SwrDb = v
|
||||
c.status.Vswr = returnLossToVswr(v)
|
||||
|
||||
@@ -629,3 +629,11 @@ func isTimeout(err error) bool {
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "timeout")
|
||||
}
|
||||
|
||||
// TraceEnabled reports whether the protocol trace is running.
|
||||
//
|
||||
// The settings dialog needs it: the checkbox is React state that starts false on
|
||||
// every mount, so a trace switched on stayed on in the backend while the box
|
||||
// reappeared unticked — the operator ticks it again, which switches it OFF, and
|
||||
// the log they then send has no trace in it at all.
|
||||
func TraceEnabled() bool { return traceOn.Load() }
|
||||
|
||||
+24
-7
@@ -6,12 +6,22 @@ import (
|
||||
"hamlog/internal/adif"
|
||||
)
|
||||
|
||||
// What counts as a QRZ.com confirmation.
|
||||
// What counts as a QRZ.com confirmation: app_qrzlog_status = C, and nothing else.
|
||||
//
|
||||
// Reported 2026-07-29: after a confirmation download, every QSO turned to Y. The
|
||||
// test accepted qsl_rcvd = Y — but that is the operator's own PAPER QSL flag,
|
||||
// which they uploaded to QRZ themselves, so every QSO with a card came back
|
||||
// looking QRZ-confirmed. On a log full of paper QSLs that is most of it.
|
||||
// Narrowed twice, each time on evidence from an operator's own log.
|
||||
//
|
||||
// 2026-07-29 — qsl_rcvd = Y was accepted. That is the operator's own PAPER QSL
|
||||
// flag, which they uploaded to QRZ themselves, so every QSO with a card came
|
||||
// back looking QRZ-confirmed.
|
||||
//
|
||||
// 2026-07-31 — qrzcom_qso_download_status = Y was accepted, and this test said
|
||||
// it should be. A fetch showed both fields on ONE record:
|
||||
//
|
||||
// <app_qrzlog_status:1>N <qrzcom_qso_download_status:1>Y
|
||||
//
|
||||
// QRZ says not confirmed and sets the download field anyway: it marks what was
|
||||
// handed back, not what was confirmed. Eighteen QSOs, all "UPDATED", all green,
|
||||
// none of them confirmed on QRZ.
|
||||
//
|
||||
// This status feeds the award slots, so a false Y is a QSO counted as confirmed
|
||||
// when it is not — the reason the rule is narrow rather than generous.
|
||||
@@ -21,8 +31,7 @@ func TestQRZRecordConfirmed(t *testing.T) {
|
||||
rec adif.Record
|
||||
want bool
|
||||
}{
|
||||
// QRZ's own statements.
|
||||
{"QRZ download status Y", adif.Record{"qrzcom_qso_download_status": "Y"}, true},
|
||||
// QRZ's own statement.
|
||||
{"QRZ log status C", adif.Record{"app_qrzlog_status": "C"}, true},
|
||||
{"lower case is still QRZ's answer", adif.Record{"app_qrzlog_status": "c"}, true},
|
||||
{"padded", adif.Record{"app_qrzlog_status": " C "}, true},
|
||||
@@ -33,6 +42,14 @@ func TestQRZRecordConfirmed(t *testing.T) {
|
||||
{"LoTW confirmed, QRZ silent", adif.Record{"lotw_qsl_rcvd": "Y"}, false},
|
||||
{"eQSL confirmed, QRZ silent", adif.Record{"eqsl_qsl_rcvd": "Y"}, false},
|
||||
|
||||
// The download flag is not an answer: QRZ sets it on everything it returns.
|
||||
{"downloaded, not confirmed", adif.Record{"qrzcom_qso_download_status": "Y"}, false},
|
||||
{"the record from the report", adif.Record{
|
||||
"call": "IQ4J", "band": "17m", "mode": "FT8",
|
||||
"qrzcom_qso_upload_status": "Y", "app_qrzlog_status": "N",
|
||||
"qrzcom_qso_download_date": "20260731", "qrzcom_qso_download_status": "Y",
|
||||
}, false},
|
||||
|
||||
// Explicit negatives and nothing at all.
|
||||
{"QRZ says no", adif.Record{"qrzcom_qso_download_status": "N"}, false},
|
||||
{"QRZ status not confirmed", adif.Record{"app_qrzlog_status": "N"}, false},
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// Which modes produce a recording worth keeping.
|
||||
//
|
||||
// CW has moved in and out of this list: it was excluded because SmartSDR does
|
||||
// not route the operator's own sidetone through DAX, then restored because the
|
||||
// other station is captured all the same. Pinned so it does not drift back.
|
||||
func TestRecordableMode(t *testing.T) {
|
||||
for _, m := range []string{"SSB", "USB", "LSB", "AM", "FM", "DV", "CW", "cw", " CW "} {
|
||||
if !recordableMode(m) {
|
||||
t.Errorf("recordableMode(%q) = false — it should be recorded", m)
|
||||
}
|
||||
}
|
||||
// Digital audio is a modem tone nobody replays.
|
||||
for _, m := range []string{"FT8", "FT4", "RTTY", "PSK31", "JT65", "", "DIGU"} {
|
||||
if recordableMode(m) {
|
||||
t.Errorf("recordableMode(%q) = true — digital modes carry no useful audio", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.22.2"
|
||||
appVersion = "0.23.1"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
@@ -1,13 +1,47 @@
|
||||
# Amplifiers and Switches
|
||||
|
||||
## PowerGenius XL (4O3A) amplifier
|
||||
## Amplifiers
|
||||
|
||||
Direct TCP connection (Settings → PowerGenius):
|
||||
Settings → **Amplifier** is a **list**: configure several amps (e.g. two SPEs
|
||||
run in parallel), each with its own name and connection. Brands:
|
||||
|
||||
- **Operate / Standby** and **fault** display.
|
||||
- **Fan-mode** selector (Standard / Contest / Broadcast).
|
||||
- When a Flex is connected, the amp controls + its **FWD / ID / TEMP** meters
|
||||
appear in the FlexRadio panel's Amplifier card. See [[FlexRadio]].
|
||||
- **SPE Expert** (1.3K / 1.5K / 2K) — USB serial or an RS232-to-Ethernet
|
||||
bridge. OPERATE/STANDBY, ON/OFF, Low/Mid/High power level, live status.
|
||||
Switching the amplifier **on and off** uses the remote-on control lines, so
|
||||
it needs the **USB (or RS-232) connection**, not a network bridge — the ON
|
||||
button works even while the amplifier is off and reporting nothing.
|
||||
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) — serial or bridge.
|
||||
OPERATE/STANDBY/OFF, live power / SWR / PA temperature / band / fan.
|
||||
Power-ON works over serial when the cable wires the DTR/RTS pins.
|
||||
- **PowerGenius XL** (4O3A) — direct TCP. Operate/Standby, fault display, and a
|
||||
**fan-mode** selector (Standard / Contest / Broadcast).
|
||||
|
||||
Where the controls appear:
|
||||
|
||||
- An **amplifier card** in the FlexRadio panel and in **Station Control**:
|
||||
OPERATE/STANDBY, ON/OFF, power level, output-power bar and live FWD /
|
||||
drain-current / temperature meters. Several amps → one card each.
|
||||
- A **status chip per amp** in the bottom bar: green ON AIR = OPERATE, orange =
|
||||
STANDBY, red = offline. Clicking it toggles OPERATE/STANDBY.
|
||||
- A **docked widget** (flame icon, next to the keyers): Operate, power ON/OFF,
|
||||
the SPE power level and live watts / SWR / temperature, in the narrow column
|
||||
format of the Tuner Genius widget. With several amplifiers configured, a
|
||||
dropdown on the icon picks which one it shows — or **All**.
|
||||
|
||||
### Band-follow (amp on a second COM port)
|
||||
|
||||
An ACOM or SPE that normally polls the radio for its frequency can follow
|
||||
OpsLog instead: OpsLog answers the amp's polls **as a Kenwood rig** on a second
|
||||
COM port (Settings → Amplifier → frequency port). Useful when the real radio's
|
||||
CAT port is already taken by OpsLog itself.
|
||||
|
||||
## Tuner Genius XL (4O3A)
|
||||
|
||||
Settings → Tuner Genius (IP only; port fixed at 9010). Live **SWR and forward
|
||||
power** with peak-hold, **Tune / Bypass / Operate-Standby**, and the two
|
||||
channels A/B (source, frequency, antenna) shown and click-selectable. Available
|
||||
as a docked widget, a card in the FlexRadio panel and a card in Station
|
||||
Control. Direct TCP — uses one of the box's connection slots.
|
||||
|
||||
## Antenna Genius (4O3A) antenna switch
|
||||
|
||||
@@ -16,8 +50,29 @@ Over TCP / GSCP — a docked **A/B antenna-switch** widget:
|
||||
- One row per configured antenna, with a **Port A** and **Port B** button.
|
||||
- Colours: green = selected on port A, blue = selected on port B, red (pulsing) =
|
||||
that port is transmitting. Clicking a selected port deselects it (→ None).
|
||||
- **Band filter** (funnel icon in the header): shows only the antennas configured
|
||||
for the current band, like the native app. Toggle it off to see all antennas.
|
||||
Antennas with an unknown band mask are always shown.
|
||||
- **Band filter** (funnel icon): shows only the antennas configured for the
|
||||
current band. Antennas with an unknown band mask are always shown.
|
||||
|
||||
Configure the host / password in Settings → Antenna Genius.
|
||||
|
||||
## Relay boards (Station Control)
|
||||
|
||||
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
|
||||
|
||||
The Station Control tab is a dashboard of fixed-width panels that wrap to fill
|
||||
the window — amplifier(s), tuner, relays, rotator, Ultrabeam… Drag a panel by
|
||||
the grip on its left edge to reorder; the order is remembered.
|
||||
|
||||
+53
-16
@@ -2,35 +2,72 @@
|
||||
|
||||
## CW keyer
|
||||
|
||||
A CW keyer with **macros** and F-key macros. The keyer **engine** is selectable
|
||||
(Settings → CW Keyer):
|
||||
A CW keyer with **macros** on F-keys (F1–F9; empty slots are hidden). The keyer
|
||||
**engine** is selectable (Settings → CW Keyer):
|
||||
|
||||
- **WinKeyer** — K1EL WK1/2/3 over a COM port.
|
||||
- **WinKeyer** — K1EL WK1/2/3 over a COM port. The log names the generation
|
||||
detected, and a byte-level trace can be enabled for bug reports.
|
||||
- **Serial port (DTR=CW / RTS=PTT)** — OpsLog keys CW by toggling a serial
|
||||
control line itself, the hardware method N1MM/WSJT-X use with a Yaesu SCU-17
|
||||
or a generic keying interface. No WinKeyer needed. Choose the COM port, which
|
||||
line carries CW, and *Invert keying* for interfaces that key backwards. This
|
||||
is also the engine for an **FTDX10** (whose CAT refuses `KY`), on the rig's
|
||||
second COM port.
|
||||
- **Icom** — the radio's own keyer over CI-V, no extra hardware. Works over the
|
||||
remote link too. (Requires the Icom CAT backend; set break-in to SEMI/FULL or
|
||||
the rig keys the sidetone but stays in RX.)
|
||||
remote link too. (Set break-in to SEMI/FULL or the rig keys the sidetone but
|
||||
stays in RX.)
|
||||
- **FlexRadio (CWX)** — keys CW straight over the SmartSDR connection, with
|
||||
type-ahead and mid-send backspace.
|
||||
- **Yaesu (rig keyer)** — the radio's internal keyer over CAT (`KY`), for the
|
||||
FTDX101 / FT-991A / FT-710 family. See [[Yaesu]].
|
||||
- **TCI** — for TCI backends.
|
||||
|
||||
**ESC** stops CW on whichever engine is active; typing a callsign aborts a
|
||||
running macro. `<LOGQSO>` inside a macro logs the contact at that exact point.
|
||||
**Auto-call** repeats a CQ with the gap you set measured *after* the message.
|
||||
|
||||
### ESM — Enter Sends Message
|
||||
|
||||
N1MM-style, for CW (Settings → CW Keyer). With the keyer on, **Enter** fires a
|
||||
macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign
|
||||
entered → F2 (report), focus jumps to RST; Enter in RST → F3 (TU), which logs
|
||||
if the macro contains `<LOGQSO>`.
|
||||
|
||||
## Digital Voice Keyer (DVK)
|
||||
|
||||
Record **F1–F6** voice messages and transmit them. Set the audio devices, PTT
|
||||
method and gains in Settings → Audio.
|
||||
|
||||
- **PTT** method: CAT, RTS, DTR, or none (VOX).
|
||||
- **Test PTT** keys the rig briefly. On CAT/OmniRig the key is held ~1.5 s so the
|
||||
transmit command is actually sent (OmniRig is asynchronous and coalesces rapid
|
||||
writes).
|
||||
- **PTT** method: CAT (whichever backend is active — every one supports PTT),
|
||||
RTS, DTR, or none (VOX).
|
||||
- **Auto CQ** repeats a CQ-labelled message on a timer until you stop it.
|
||||
- The DVK refuses to transmit on non-phone modes — no speech on FT8.
|
||||
- A **message level** control (Settings → Audio) drives the rig at the right
|
||||
level even from a quietly recorded microphone. If the rig transmits almost
|
||||
nothing, check its rear-input setting (on an FTDX10: SSB MOD SOURCE = REAR).
|
||||
|
||||
## QSO audio recording
|
||||
|
||||
Continuous rolling capture. On **Log QSO** the contact is saved to a per-QSO WAV
|
||||
(`CALL_YYYYMMDD_HHMMSS.wav`), mixing RX + mic. Configure the recordings folder,
|
||||
format (WAV / MP3), pre-roll and gains in Settings → Audio.
|
||||
Continuous rolling capture. On **Log QSO** the contact is saved to a per-QSO
|
||||
WAV/MP3 (`CALL_YYYYMMDD_HHMMSS`), with pre-roll. With automatic recording off, a
|
||||
**red dot** beside the callsign records the contact by hand.
|
||||
|
||||
A recording can be **replayed on the air** to the station being worked, like a
|
||||
voice-keyer message — with its **own level**, separate from the voice keyer (a
|
||||
receiver line-out needs far less gain than a microphone). The play button
|
||||
becomes a **stop** button while it transmits. Hidden on CW.
|
||||
|
||||
## CW decoder (RX audio → text)
|
||||
|
||||
**Tools → CW decoder** (or the ear button) decodes received CW while the mode
|
||||
is CW: adaptive envelope with noise squelch, per-character dit/dah
|
||||
classification, follows 12–40 WPM and sloppy fists, rides QSB. Shows WPM /
|
||||
pitch / level, locks onto the FlexRadio CW pitch automatically, and clicking a
|
||||
decoded word fills the callsign. Clean-to-moderate signals decode solidly;
|
||||
weak-signal pileups remain CW Skimmer territory.
|
||||
|
||||
## RX audio monitor (USB rigs)
|
||||
|
||||
Settings → Audio → **Listen to radio** pipes the rig's received audio (its "USB
|
||||
Audio CODEC" input) through your speakers, so you hear the radio inside OpsLog.
|
||||
**Talk to radio** keys PTT and pipes your mic to the rig's audio input. (Network
|
||||
audio for remote Icom is a work in progress — see
|
||||
[[Remote Icom over the Internet]].)
|
||||
Audio CODEC" input) through your speakers — the level applies live as you drag
|
||||
it. **Talk to radio** keys PTT and pipes your mic to the rig's audio input.
|
||||
|
||||
+18
-4
@@ -6,8 +6,15 @@ shared **globally** across profiles.
|
||||
## Built-in awards
|
||||
|
||||
DXCC, WAS / WAZ / WAC, WPX, IOTA / POTA / SOTA / WWFF, **DDFM** (French
|
||||
departments), and more — tracked **worked / confirmed / validated** by band and
|
||||
mode.
|
||||
departments), **DLD** (DARC DOK), **H26** (Helvetia 26 cantons), the US
|
||||
counties (USA-CA), and more — tracked **worked / confirmed / validated** by
|
||||
band and mode.
|
||||
|
||||
The awards panel has a **mode filter** (All / CW / Phone / Digital) that
|
||||
applies to the whole award — bands, counts, confirmations and the contacts
|
||||
behind a cell — and stacks with the worked/confirmed filter, so "worked on CW
|
||||
but not confirmed on CW" is one click. Reference lists sort by reference or by
|
||||
description.
|
||||
|
||||
## How matching works
|
||||
|
||||
@@ -121,13 +128,20 @@ drop off the list. This is how you close the gaps a matcher can't fill on its ow
|
||||
|
||||
## Live detection & manual refs
|
||||
|
||||
- References are detected **live** as you enter a callsign.
|
||||
- References are detected **live** as you enter a callsign — from the looked-up
|
||||
address *and* from the QTH, name, country, comment, note or grid you typed.
|
||||
- An award can be marked **one reference per QSO** (used by DLD). When several
|
||||
references then match the same contact, none is assigned silently — the
|
||||
candidates are offered at log time and you pick. Off by default, so an n-fer
|
||||
POTA activation still counts every park.
|
||||
- You can **manually assign** a reference to a QSO (the *Award Refs* tab of the
|
||||
QSO editor). It's stored in an ADIF extra (`APP_OPSLOG_AWARDREFS`) so it
|
||||
survives export/import — see [[Import and Export ADIF]] — and is honoured
|
||||
everywhere (award panel, grid columns, totals). For a list-backed award the
|
||||
assigned reference must still be a **valid, listed** reference, and the QSO
|
||||
must be **in scope** (rule 5) for it to count.
|
||||
must be **in scope** (rule 5) for it to count. A hand-assigned reference
|
||||
**replaces** what the matcher found, so a correction survives the next
|
||||
recompute.
|
||||
|
||||
## Reference lists & display
|
||||
|
||||
|
||||
+52
-14
@@ -1,28 +1,46 @@
|
||||
# CAT Control
|
||||
|
||||
OpsLog has **four native CAT backends** (Settings → CAT). Each auto-reconnects
|
||||
and connects *non-blocking*, so a powered-off radio never freezes the app.
|
||||
OpsLog has **eight CAT backends** (Settings → CAT). Each auto-reconnects and
|
||||
connects *non-blocking*, so a powered-off radio never freezes the app. Backends
|
||||
are named by **how the radio is reached**:
|
||||
|
||||
| Backend | Use it for |
|
||||
|---------|-----------|
|
||||
| **OmniRig** | Any OmniRig-supported rig (Rig 1 / Rig 2, hot-swap). |
|
||||
| **FlexRadio (SmartSDR)** | FlexRadio 6000/8000 over the TCP API. → [[FlexRadio]] |
|
||||
| **Icom CI-V** | Any Icom over **USB** *or* over the internet via its **LAN server**. → [[Icom]] · [[Remote Icom over the Internet]] |
|
||||
| **FlexRadio (API)** | FlexRadio 6000/8000 over the TCP API. → [[FlexRadio]] |
|
||||
| **Yaesu (USB)** | FTDX10 / FTDX101 family, direct serial — no OmniRig. → [[Yaesu]] |
|
||||
| **Kenwood (USB, network)** | TS-590 / TS-890 / TS-990 / TS-2000 (Elecraft K3/K4 speak the same dialect), serial or a `host:port` serial bridge. → [[Kenwood and Xiegu]] |
|
||||
| **Xiegu (USB)** | G90 / X6100 / X6200 / X5105 — CI-V with their reduced command set. → [[Kenwood and Xiegu]] |
|
||||
| **Icom (CI-V USB)** | Any Icom over USB. → [[Icom]] |
|
||||
| **Icom (CI-V network)** | An Icom's built-in LAN/internet server, replacing RS-BA1. → [[Remote Icom over the Internet]] |
|
||||
| **TCI** | SunSDR / ExpertSDR2 and any TCI (WebSocket) server. |
|
||||
|
||||
Once connected, frequency / band / mode follow the radio, clicking a cluster spot
|
||||
tunes the rig, and (for Flex) cluster spots can be pushed to the panadapter.
|
||||
Once connected, frequency / band / mode follow the radio, clicking a cluster
|
||||
spot tunes the rig, and (for Flex) cluster spots can be pushed to the panadapter.
|
||||
|
||||
## Sharing the rig with WSJT-X / JTDX / MSHV (rigctld)
|
||||
|
||||
A native backend holds the radio's serial port **exclusively** — so OpsLog can
|
||||
serve its rig connection to other programs instead: **Settings → CAT → Share
|
||||
CAT**. In WSJT-X / JTDX / MSHV / Log4OM pick rig model **Hamlib NET rigctl** at
|
||||
`127.0.0.1:4532`. Works with every backend and both Hamlib command dialects.
|
||||
|
||||
> Never point two programs at the same COM port. If another logger or WSJT-X
|
||||
> opens the rig's port directly while OpsLog holds it, you get "port busy"
|
||||
> errors, garbled CI-V frames and erratic rig behaviour. One master — OpsLog —
|
||||
> and everyone else through the shared rigctl port.
|
||||
|
||||
## OmniRig
|
||||
|
||||
Pick **Rig 1** or **Rig 2** (the two OmniRig slots). Configure the actual COM
|
||||
port / baud in OmniRig's own settings. Works with anything OmniRig supports; PTT
|
||||
keying via CAT is supported where the rig's OmniRig profile exposes it.
|
||||
port / baud in OmniRig's own settings.
|
||||
|
||||
## FlexRadio
|
||||
|
||||
Enter the radio's IP (UDP discovery assists). You get a full SmartSDR-style
|
||||
control tab — see **[[FlexRadio]]**.
|
||||
- **VFO to read** — some OmniRig rig files declare the wrong active VFO (an
|
||||
IC-7610 file declared VFO B). If the frequency looks frozen or follows the
|
||||
wrong VFO, force **A** or **B** here.
|
||||
- OpsLog and OmniRig must run at the **same privilege level** — one elevated
|
||||
("as administrator") and not the other cannot see each other; OpsLog detects
|
||||
and names this case.
|
||||
|
||||
## Icom CI-V (USB)
|
||||
|
||||
@@ -31,7 +49,7 @@ control tab — see **[[FlexRadio]]**.
|
||||
- **COM port** + **baud** must match the radio (set *CI-V USB Echo Back* **OFF**
|
||||
on the rig).
|
||||
- The model choice also tailors the console (e.g. attenuator steps: 20 dB on an
|
||||
IC-7300, 6/12/18 dB on an IC-7610).
|
||||
IC-7300, 6/12/18 dB on an IC-7610/7800).
|
||||
|
||||
Full console details: **[[Icom]]**.
|
||||
|
||||
@@ -40,10 +58,30 @@ Full console details: **[[Icom]]**.
|
||||
Enter the TCI host + port (default 40001). Gives freq / mode / PTT / split and,
|
||||
optionally, panorama spots.
|
||||
|
||||
## Serial lines (DTR / RTS)
|
||||
|
||||
Windows raises the DTR and RTS lines when a serial port opens, and some
|
||||
interfaces read one of them as **PTT** — the radio goes into transmit the moment
|
||||
OpsLog connects. Others need the lines **high** to work at all. So:
|
||||
|
||||
- **Xiegu** always lowers both on connect (a G90 behind a DE-19 keyed on open),
|
||||
and has a setting for which line keys the rig.
|
||||
- **Yaesu and Kenwood** have an option (off by default) to lower both lines on
|
||||
connect — turn it on only if your interface transmits as soon as OpsLog opens
|
||||
the port.
|
||||
- **Icom CI-V** has always dropped them.
|
||||
|
||||
## Protocol trace
|
||||
|
||||
Settings → CAT can log the **raw protocol** (every CI-V frame, or every Kenwood
|
||||
command, as hex/text) to the diagnostic log — invaluable when reporting a radio
|
||||
that answers oddly. The checkbox shows whether the trace is really running.
|
||||
|
||||
## Notes
|
||||
|
||||
- The **digital sub-mode** (FT4 vs FT8) is inferred from the frequency.
|
||||
- A radio reporting LSB or USB selects **SSB** in the entry form.
|
||||
- **Per-band Flex RX/TX antennas** can be configured and are applied
|
||||
automatically on band change (Settings → FlexRadio).
|
||||
automatically on band change (Settings → CAT → FlexRadio).
|
||||
- Split, RIT/XIT and other rig-specific features are exposed on each backend's
|
||||
control tab where supported.
|
||||
|
||||
@@ -3,27 +3,39 @@
|
||||
## Servers
|
||||
|
||||
Add multiple cluster servers (Settings). They auto-reconnect; one is the
|
||||
**master** used for sending commands. The Cluster tab and the Main-view cluster
|
||||
pane share the same live feed.
|
||||
**master** used for sending commands. A quiet node is not treated as a dead one
|
||||
— silence keeps its session, login and filters. The Cluster tab and the
|
||||
Main-view cluster pane share the same live feed.
|
||||
|
||||
## Filter sidebar
|
||||
|
||||
A show/hide **filter sidebar** (shared by the Cluster tab and the Main pane):
|
||||
|
||||
- **Callsign** search, **hide worked**, **group duplicates**.
|
||||
- Filter by **band / mode / status / source**.
|
||||
- Filter by **band / mode / status / source**, plus **NEW POTA** and
|
||||
**NEW COUNTY** chips.
|
||||
|
||||
## Per-spot status and tuning
|
||||
|
||||
Each spot is coloured by **status** relative to your log:
|
||||
|
||||
- **new** (entity never worked), **new-band**, **new-slot**, **worked**.
|
||||
- **new** (entity never worked), **new-band**, **new-mode**, **new-slot**,
|
||||
**worked**.
|
||||
- Settings → General → **Group digital modes as one (DXCC-style)** makes
|
||||
FT8/FT4/RTTY/PSK count as a single Digital mode for the colouring and the
|
||||
matrix badges, matching how DXCC counts.
|
||||
|
||||
Click a spot to **tune the rig** (fills the callsign into the entry strip). A
|
||||
multi-band **Band Map** shows panadapter-style strips per band.
|
||||
multi-band **Band Map** shows panadapter-style strips per band — its CW / data
|
||||
/ phone sub-bands use their own colour-blind-checked hues, and **Ctrl+↑ /
|
||||
Ctrl+↓** jumps to the next spot above/below the current frequency and tunes it.
|
||||
|
||||
**POTA** spots are tagged with their park reference (via `api.pota.app`).
|
||||
|
||||
The entry strip's band matrix can also show the **ClubLog Most Wanted rank**
|
||||
(Settings → General): an `MW #rank` pill next to the entity name, personalised
|
||||
to your callsign, refreshed daily.
|
||||
|
||||
## Sending a spot
|
||||
|
||||
Use the **Send Spot** window to announce a DX spot on the master cluster:
|
||||
@@ -35,6 +47,12 @@ frequency** (full resolution, including sub-kHz).
|
||||
**Tools → Alert management** — Log4OM-style alert rules on **call / country /
|
||||
band / mode / spotter**, with **sound**, **visual** and **e-mail** notification.
|
||||
Active alerts appear as a discreet bell in the entry strip; click it for the
|
||||
list. Alerts are kept briefly and only shown when active.
|
||||
list.
|
||||
|
||||
## External spot sources
|
||||
|
||||
A **DXHunter** remote-call UDP packet fills the callsign, and when it carries
|
||||
`<FREQ>` and `<MODE>` it also tunes whichever CAT backend is active. See
|
||||
[[Settings and Data]] for the UDP configuration.
|
||||
|
||||
See also: [[Maps and Antennas]] for turning the beam to a spot.
|
||||
|
||||
+8
-6
@@ -1,10 +1,11 @@
|
||||
# OpsLog
|
||||
|
||||
A modern, fast ham-radio logger for **Windows** — Log4OM-style entry, real-time
|
||||
CAT for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB
|
||||
*and* remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert
|
||||
Electronics), a DX cluster with spot alerts, awards tracking, maps, contest
|
||||
logging, QSL management and a QSL-card designer.
|
||||
CAT for **OmniRig**, native **FlexRadio/SmartSDR**, **Icom CI-V** (USB *and*
|
||||
remote-over-internet, replacing RS-BA1), **Yaesu**, **Kenwood**, **Xiegu** and
|
||||
**TCI** (SunSDR / Expert Electronics) — with **CAT sharing** so WSJT-X & co use
|
||||
the same rig link — a DX cluster with spot alerts, awards tracking, maps,
|
||||
contest logging, QSL management and a QSL-card designer.
|
||||
|
||||
Built with **Wails v2** (Go backend + React/TypeScript frontend), **pure Go**
|
||||
(no CGO). SQLite holds the configuration; the logbook can be the local SQLite
|
||||
@@ -18,14 +19,15 @@ Developed by **F4BPO**.
|
||||
1. **[[Installation]]** — download / build and first launch.
|
||||
2. **[[Getting Started]]** — the entry strip, logging your first QSO, choosing a
|
||||
profile.
|
||||
3. **[[CAT Control]]** — connect your radio (OmniRig, FlexRadio, Icom, TCI).
|
||||
3. **[[CAT Control]]** — connect your radio (OmniRig, FlexRadio, Icom, Yaesu,
|
||||
Kenwood, Xiegu, TCI).
|
||||
|
||||
## What OpsLog does
|
||||
|
||||
| Area | Pages |
|
||||
|------|-------|
|
||||
| Enter and manage QSOs | [[Logging Basics]] · [[Recent QSOs and Filters]] · [[Import and Export ADIF]] |
|
||||
| Control your radio | [[CAT Control]] · [[FlexRadio]] · [[Icom]] · [[Remote Icom over the Internet]] |
|
||||
| Control your radio | [[CAT Control]] · [[FlexRadio]] · [[Icom]] · [[Remote Icom over the Internet]] · [[Yaesu]] · [[Kenwood and Xiegu]] |
|
||||
| Chase DX | [[DX Cluster and Spots]] · [[Maps and Antennas]] |
|
||||
| Station hardware | [[Amplifiers and Switches]] · [[Audio and Keyers]] |
|
||||
| Events & contests | [[Contest Logging]] · [[Net Control]] · [[Multi-Operator Live Status]] |
|
||||
|
||||
@@ -8,22 +8,34 @@ export modes.
|
||||
|
||||
Import an `.adi` / `.adif` file into the active profile's logbook. On import,
|
||||
OpsLog parses every field, maps standard fields to columns and keeps unknown but
|
||||
valid ADIF fields in a per-QSO **extras** store (editable later). Country / zones
|
||||
are enriched from `cty.dat` where missing.
|
||||
valid ADIF fields in a per-QSO **extras** store (editable later). Country /
|
||||
zones are enriched from `cty.dat` where missing. A file **without a header**
|
||||
(records pasted from another log) imports fine.
|
||||
|
||||
- **Field remapping**: the import dialog can move a field as it reads it —
|
||||
e.g. the RSGB IOTA contest exports the island reference in `STATE`; add a
|
||||
`STATE → IOTA` row and it lands where the award looks.
|
||||
- **Fill from profile**: optionally stamps your station fields *and* your
|
||||
default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog,
|
||||
QRZ.com) on QSOs the file leaves empty — a WSJT-X log carries almost none.
|
||||
Existing values are never overwritten.
|
||||
|
||||
## Export
|
||||
|
||||
- **Whole log**, **selected rows**, or the **filtered view** (no limit) — see
|
||||
[[Recent QSOs and Filters]].
|
||||
- **Standard** mode exports the common promoted fields; **All** mode also exports
|
||||
every extra field.
|
||||
- **Standard** mode exports the common promoted fields; **All** mode also
|
||||
exports every extra field; **Choose fields…** opens a picker that separates
|
||||
the official ADIF 3.1.7 fields (by category) from the OpsLog / non-standard
|
||||
tags actually present in your log, with All/None per group. Your selection is
|
||||
remembered.
|
||||
- **Cabrillo** export for contest submission.
|
||||
|
||||
## Round-trip safety
|
||||
|
||||
Award references you assign manually are stored in an ADIF extra field
|
||||
(`APP_OPSLOG_AWARDREFS`), so exporting your full log and re-importing it does not
|
||||
lose your award-reference assignments.
|
||||
(`APP_OPSLOG_AWARDREFS`), so exporting your full log and re-importing it does
|
||||
not lose your award-reference assignments.
|
||||
|
||||
## Tips
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Kenwood and Xiegu (native CAT)
|
||||
|
||||
## Kenwood (USB, network)
|
||||
|
||||
A native backend talks straight to a **TS-590 / TS-890 / TS-990 / TS-2000**
|
||||
over its serial port — frequency, mode, VFO, split and PTT, no OmniRig in
|
||||
between. **Elecraft K3/K4** speak the same dialect. Pick **Kenwood (USB,
|
||||
network)** in Settings → CAT.
|
||||
|
||||
- **Serial**: COM port + baud.
|
||||
- **Network**: give a `host:port` instead — for a serial bridge (ser2net, an
|
||||
Ethernet-serial adapter). *Not* the radio's own RJ45, which speaks Kenwood's
|
||||
KNS/ARCP protocol.
|
||||
- Split is detected from FR/FT when the radio omits it from its status frame.
|
||||
- The backend was tested end-to-end against a TS-2000 emulator and reports
|
||||
useful errors: a port that opens but stays silent is distinguished from one
|
||||
sending data that never answers (the error quotes what arrived).
|
||||
|
||||
## Xiegu (G90, X6100, X6200, X5105)
|
||||
|
||||
These radios speak CI-V but with a **reduced command set** (no scope, no DSP
|
||||
block, no data mode), so they have their own backend — pick **Xiegu (USB)** in
|
||||
Settings → CAT. Frequency, mode, split and PTT.
|
||||
|
||||
### PTT on a G90 — read this
|
||||
|
||||
A **G90 ignores the CI-V PTT command**. That has three consequences:
|
||||
|
||||
1. In the Xiegu settings, choose **how the rig is keyed**: CI-V command, RTS or
|
||||
DTR — pick the hardware line your interface wires to PTT.
|
||||
2. WSJT-X & co transmit through OpsLog's **shared CAT link** (Hamlib NET rigctl
|
||||
`127.0.0.1:4532` — see [[CAT Control]]): OpsLog converts the PTT request
|
||||
into the hardware line. This is the supported path and it works.
|
||||
3. **WSJT-X + OmniRig will NOT transmit** a G90: OmniRig sends PTT as a CI-V
|
||||
command, which the radio ignores. Either use the native Xiegu backend +
|
||||
rigctl sharing, or configure the PTT line (RTS/DTR) inside OmniRig itself.
|
||||
|
||||
### Keying on connect
|
||||
|
||||
Behind an interface that carries PTT on a modem line (e.g. a DE-19), a G90 used
|
||||
to go **straight into transmit when OpsLog connected** — Windows raises DTR and
|
||||
RTS when a serial port opens. The Xiegu backend now drops both lines on
|
||||
connect, then drives only the line you selected for keying.
|
||||
|
||||
## Notes
|
||||
|
||||
- Both backends refuse a CI-V/CAT frame read at the wrong offset, so a garbled
|
||||
answer can no longer turn into a phantom frequency.
|
||||
- The protocol trace (Settings → CAT) covers both — see [[CAT Control]].
|
||||
+31
-8
@@ -10,8 +10,24 @@ save. Fields: callsign, RST tx/rx, name, QTH, grid, band, mode, TX/RX frequency
|
||||
CQ/ITU zones, continent) from `cty.dat` including `/MM` `/AM` `/B` and call-area
|
||||
handling (`/8`, `/W6`), plus **ClubLog** DXpedition date overrides.
|
||||
- **Callsign lookup** (QRZ.com / HamQTH) fills name / QTH / grid and shows the
|
||||
photo and a QRZ.com tab. Configure it in Settings → Lookup.
|
||||
photo and a QRZ.com tab. Configure it in Settings → Lookup. Portable calls
|
||||
(`/M`, `/P`) are looked up on the home call too.
|
||||
- **No lookup account?** The name / QTH / locator are recovered from the **last
|
||||
time you worked the station**, and for US calls the offline **FCC (ULS)**
|
||||
database fills state, county and a 6-character grid as you type. A real
|
||||
QRZ/HamQTH hit still wins.
|
||||
- A green **NEW** badge appears on the County field (Info / F2) when the US
|
||||
county has never been worked.
|
||||
- **ESC** clears the callsign (and stops CW).
|
||||
- The frequency readout is **scroll-tunable**: roll the wheel over the
|
||||
hundreds / tens / units-of-kHz digit and the rig follows.
|
||||
|
||||
## Super Check Partial / N+1
|
||||
|
||||
Enable in Settings → General (downloads the community MASTER.SCP list). As you
|
||||
type a call, a docked two-column widget shows known calls that **contain** what
|
||||
you typed (Partial) and calls **one character away** (N+1) — click one to fix a
|
||||
busted call.
|
||||
|
||||
## Editing a QSO
|
||||
|
||||
@@ -22,14 +38,18 @@ fields** editor for any ADIF field not promoted to a column.
|
||||
|
||||
## Bulk operations
|
||||
|
||||
Select rows in Recent QSOs, right-click:
|
||||
Select rows in Recent QSOs (a **Select all** button takes every displayed row),
|
||||
right-click:
|
||||
|
||||
- **Fix country & zones** from `cty.dat`, or **update from QRZ / ClubLog**.
|
||||
- **Bulk edit field** — set one field across many QSOs (QSL/upload status, my
|
||||
station, contest, propagation, contacted-station refs, comment…). Great for
|
||||
flipping a batch to *Requested* before an upload.
|
||||
- **Fix country & zones** from `cty.dat`, or **update from the callsign
|
||||
databases** (QRZ / ClubLog), with a progress bar.
|
||||
- **Bulk edit field** — set one field across many QSOs: QSL/confirmation
|
||||
statuses *and dates*, my station, contest, propagation, contacted-station
|
||||
refs, comment, **gridsquare**, even the **frequency** (band recomputed,
|
||||
out-of-band values refused).
|
||||
- **Send to** a QSL service, **export** selected / filtered to ADIF or Cabrillo,
|
||||
**delete**.
|
||||
**delete** (optionally withdrawing the QSO from QRZ.com and Club Log —
|
||||
Settings → External services, off by default).
|
||||
|
||||
## Find duplicates
|
||||
|
||||
@@ -40,6 +60,9 @@ same day or same minute) and lets you pick which to delete.
|
||||
|
||||
- **Worked-before matrix** shows prior contacts with the entity per band/mode
|
||||
slot as you type.
|
||||
- Awards references are detected **live** as you enter a call (see [[Awards]]).
|
||||
- Award references are detected **live** as you enter a call — including from
|
||||
the QTH, name, comment or grid you typed (see [[Awards]]).
|
||||
- The callsign in the top bar is the **station switcher**: click it to change
|
||||
profile.
|
||||
|
||||
See also: [[Recent QSOs and Filters]] · [[Import and Export ADIF]]
|
||||
|
||||
+27
-15
@@ -3,36 +3,48 @@
|
||||
## Main view
|
||||
|
||||
The Main view is **two configurable panes** (per profile, Settings → General →
|
||||
*Main view*). Choose from: great-circle map, locator (street) map, the cluster
|
||||
grid, the worked-before grid, recent QSOs, the **FlexRadio** controls, the
|
||||
**Icom** console, or the **Net control** panel.
|
||||
*Main view*), with a **draggable divider** between them (double-click it to
|
||||
even them out). Choose from: great-circle map, locator (street) map, the
|
||||
cluster grid, the worked-before grid, recent QSOs, the **FlexRadio** controls,
|
||||
the **Icom** or **Yaesu** console, or the **Net control** panel.
|
||||
|
||||
## Great-circle map
|
||||
|
||||
- Short/long-path **distance & azimuth** to the worked station.
|
||||
- Selectable, **key-free** basemaps: Light / Voyager / Street / Satellite (all
|
||||
labelled).
|
||||
- Selectable, **key-free** basemaps: Light / Voyager / Street / Satellite.
|
||||
- The **antenna beam lobe(s)** are drawn from the rotor azimuth.
|
||||
- **Day/night terminator** and twilight band (button at the top right).
|
||||
|
||||
## Locator (street) map
|
||||
|
||||
An OSM street map centred on the contacted grid square — handy for local /
|
||||
portable contacts. No API key.
|
||||
|
||||
## Rotor compass
|
||||
## Rotators
|
||||
|
||||
An azimuthal-equidistant **click-to-turn** compass driven by **PstRotator**. The
|
||||
current heading and target are shown; click a bearing to turn the rotor.
|
||||
An azimuthal-equidistant **click-to-turn** compass; the current heading and
|
||||
target are shown. Three rotator types (Settings → Rotator):
|
||||
|
||||
## Ultrabeam
|
||||
- **PstRotator** — via its UDP interface.
|
||||
- **microHAM ARCO** — native, over LAN or USB: set the ARCO's CONTROL PROTOCOL
|
||||
to *Yaesu GS-232A*.
|
||||
- **GS-232A (generic)** — any GS-232A controller, ERC (Easy Rotor Control)
|
||||
included; serial speed selectable. Set an ERC to GS-232 emulation, not
|
||||
Hy-Gain DCU-1.
|
||||
|
||||
Ultrabeam antennas are supported with three modes — **Normal**, **180° reverse**,
|
||||
## Motorized antennas (Ultrabeam / SteppIR)
|
||||
|
||||
Both follow the **rig frequency** (band change → retune) and retune immediately
|
||||
when you click a spot, with three modes — **Normal**, **180° reverse**,
|
||||
**Bidirectional**:
|
||||
|
||||
- The **radiating direction** is shown in green and the **mechanical boom** in
|
||||
grey, on both the compass and the map, so you always know where the antenna
|
||||
actually points.
|
||||
- The antenna **follows the rig frequency** (band change → retune), and retunes
|
||||
immediately when you click a spot.
|
||||
grey, on both the compass and the map.
|
||||
- On a FlexRadio, transmit is **inhibited while the elements move**, and
|
||||
released as soon as the antenna is tuned.
|
||||
- **SteppIR — Tunable range** (Settings → Hardware → Antenna, default
|
||||
13–54 MHz): outside it OpsLog leaves the antenna and the TX interlock
|
||||
completely alone, so tuning to 30 m on a 20 m–6 m SteppIR does nothing.
|
||||
|
||||
Related hardware: [[Amplifiers and Switches]] (Antenna Genius switch).
|
||||
Related hardware: [[Amplifiers and Switches]] (Antenna Genius switch, Tuner
|
||||
Genius, relays).
|
||||
|
||||
@@ -6,18 +6,29 @@ Every setting in OpsLog is **per-profile**: station identity, CAT, lookup, QSL
|
||||
defaults, awards display, antennas, and which logbook to use. Create one profile
|
||||
per callsign / station / event.
|
||||
|
||||
Switch or manage profiles in Settings. The active profile decides which logbook
|
||||
your QSOs go to.
|
||||
The **callsign in the top bar is the station switcher** — click it and pick a
|
||||
profile. Managing profiles is at the bottom of that list (and in Settings).
|
||||
|
||||
## Config vs. logbook — two different stores
|
||||
## Two separate databases
|
||||
|
||||
Since 0.21.0 the settings and the QSOs live in **separate files**:
|
||||
|
||||
| Store | What | Where |
|
||||
|-------|------|-------|
|
||||
| **Config** | settings, profiles, rigs/antennas, cluster nodes, lookup cache, award lists, QSL templates | always the **local SQLite** file under `data/` |
|
||||
| **Logbook** | your QSOs | where the active profile points — local SQLite **or** shared MySQL |
|
||||
| **Settings database** (`settings.db`, or `opslog.db` on older installs) | settings, profiles, rigs/antennas, cluster nodes, lookup cache, award lists, QSL templates | always the **local SQLite** file under `data/` |
|
||||
| **Logbook** (`logbook.db`) | your QSOs | where the active profile points — a local SQLite file **or** shared MySQL |
|
||||
|
||||
Keeping config local means the UI is instant even when the logbook is a far-away
|
||||
MySQL server.
|
||||
Keeping the settings local means the UI is instant even when the logbook is a
|
||||
far-away MySQL server. Settings → Database shows the two as separate sections,
|
||||
with an *Open folder* shortcut; a logbook file can be **renamed or relocated**
|
||||
(its QSOs move with it), and the status bar names the **logbook** file — that
|
||||
is the one to back up.
|
||||
|
||||
A profile can point at its **own logbook file** — ideal for a visiting
|
||||
operator, whose contacts stay out of your log without touching your settings.
|
||||
|
||||
**Portable**: paths inside the OpsLog folder are stored relative to it, so
|
||||
moving the whole folder to another drive or PC loses nothing.
|
||||
|
||||
## Shared MySQL logbook (multi-operator)
|
||||
|
||||
@@ -25,11 +36,18 @@ Point a profile's logbook at a **MySQL** database so several operators run **one
|
||||
log** — e.g. a multi-op special-event call. Configure the host / database /
|
||||
credentials in the profile's database settings.
|
||||
|
||||
This is also what powers [[Multi-Operator Live Status]] (each instance heartbeats
|
||||
its activity into a `live_status` table).
|
||||
- The database and tables are converted to **utf8mb4** on connect, so Cyrillic
|
||||
or Polish characters from a QRZ lookup store correctly even when the hosting
|
||||
panel created the database as latin1.
|
||||
- An FT8/MSHV QSO that cannot reach a laggy shared database is **parked and
|
||||
re-logged automatically** once it recovers — no lost QSOs.
|
||||
- Switching to a profile whose MySQL is unreachable shows a clear warning
|
||||
instead of silently staying on the previous logbook.
|
||||
|
||||
This is also what powers [[Multi-Operator Live Status]].
|
||||
|
||||
## Backups
|
||||
|
||||
Optional **database + ADIF backup at shutdown** (Settings → Backup). Regardless,
|
||||
copying the `data/` folder backs up your config and local logbook. See
|
||||
copying the `data/` folder backs up your settings and local logbook. See
|
||||
[[Settings and Data]].
|
||||
|
||||
@@ -4,13 +4,23 @@
|
||||
|
||||
The main log table. Double-click a row to edit; right-click for bulk actions
|
||||
(see [[Logging Basics]]). Columns are configurable — click **Columns** to choose
|
||||
which are visible; your selection is saved (per profile).
|
||||
which are visible; widths, order and hidden columns are saved (per profile),
|
||||
with a separate layout for the narrower Main-tab pane.
|
||||
|
||||
- The selected-row count is always visible at the top, and a **Select all /
|
||||
Unselect all** button takes every displayed row.
|
||||
- QSL and upload status columns are coloured: **Y** green, **N** red, **R**
|
||||
blue.
|
||||
- A **Distance (km)** column (also in Worked-before) is computed from the QSO's
|
||||
own locator pair.
|
||||
- There are **no per-column header filters** — they only ever searched the rows
|
||||
on screen, which made a QSO further back in the log look missing. Use the
|
||||
advanced filter, which queries the whole logbook.
|
||||
|
||||
### Award columns
|
||||
|
||||
Each defined award can show a column with the reference the QSO counts for. These
|
||||
columns are **hidden by default** and opt-in from the Columns picker — turn on
|
||||
only the awards you're chasing. Your choice persists across restarts.
|
||||
Each defined award can show a column with the reference the QSO counts for.
|
||||
These columns are **hidden by default** and opt-in from the Columns picker.
|
||||
|
||||
## Advanced filter builder
|
||||
|
||||
@@ -19,15 +29,19 @@ Click the filter button to open the **QSO filter builder** (Log4OM-style):
|
||||
- Add one or more **conditions**: *field · operator · value*.
|
||||
- Operators: equals, not-equal, contains, starts/ends with, greater/less
|
||||
(or equal), is empty, is not empty.
|
||||
- Fields include every confirmation **status and date** (sent / received,
|
||||
before / after) for paper QSL, LoTW, eQSL, Club Log, HRDLog and QRZ.com.
|
||||
Field names stay in English — they are ADIF names, a standard vocabulary.
|
||||
- Join conditions with **ALL (AND)** or **ANY (OR)**.
|
||||
- Save named **presets** (they travel with the `data/` folder).
|
||||
- The field dropdown is sorted alphabetically to find things fast.
|
||||
|
||||
The filtered view can be exported: **selected rows** or the **whole filtered
|
||||
view** (no row limit) → ADIF or Cabrillo.
|
||||
A filter shows **every** match (the on-screen row limit only applies to the
|
||||
unfiltered log; safety cap 10 000). The filtered view can be exported:
|
||||
**selected rows** or the **whole filtered view** → ADIF or Cabrillo.
|
||||
|
||||
## Worked-before matrix
|
||||
|
||||
As you type a callsign, the worked-before grid shows prior contacts with that
|
||||
**entity**, arranged by band/mode slot, so you instantly see what's new (new
|
||||
DXCC / new band / new slot) versus already worked.
|
||||
DXCC / new band / new slot) versus already worked. Its right-click menu offers
|
||||
the same bulk edit and export as the main grid.
|
||||
|
||||
+32
-22
@@ -4,48 +4,58 @@
|
||||
|
||||
A `data/` folder is created **next to `OpsLog.exe`**:
|
||||
|
||||
- **Config** (SQLite): settings, profiles, rigs/antennas, cluster nodes, lookup
|
||||
cache, award lists, QSL templates. Always local, so the UI is instant.
|
||||
- **Local logbook** (SQLite): QSOs, when the profile logs locally.
|
||||
- **Settings database** (SQLite): settings, profiles, rigs/antennas, cluster
|
||||
nodes, lookup cache, award lists, QSL templates. Always local.
|
||||
- **Logbook** (`logbook.db`, SQLite): QSOs, when the profile logs locally.
|
||||
- `data/qsl/templates/…` — QSL card templates and their photos.
|
||||
- `data/qsl/outbox/…` — sent QSL card JPEGs.
|
||||
- Recordings folder (configurable) — per-QSO WAV/MP3.
|
||||
- Backups.
|
||||
- Backups, diagnostic logs.
|
||||
|
||||
Back up your setup by copying `data/` (and keep it with the exe). See
|
||||
Paths are stored **relative to the folder**, so the whole OpsLog directory can
|
||||
move to another drive or PC. Back up your setup by copying `data/`. See
|
||||
[[Profiles and Databases]] for local-vs-MySQL logbooks.
|
||||
|
||||
## Settings map
|
||||
|
||||
| Section | What |
|
||||
|---------|------|
|
||||
| **General** | language, theme, Main-view panes, live-status publish |
|
||||
| **Station** | your callsign, grid, name, address (my-station ADIF) |
|
||||
| **CAT** | radio backend + connection ([[CAT Control]]) |
|
||||
| **General** | language, theme, date format (US / FR / Standard), Main-view panes, digital-modes grouping, Super Check Partial, ClubLog exceptions & Most Wanted |
|
||||
| **Station** | your callsign, grid, name, address (my-station ADIF) — hand-corrected CQ/ITU zones stay corrected |
|
||||
| **CAT** | radio backend + connection, CAT sharing (rigctld), protocol trace ([[CAT Control]]) |
|
||||
| **Lookup** | QRZ.com / HamQTH credentials |
|
||||
| **FlexRadio** | per-band RX/TX antennas |
|
||||
| **Audio** | DVK / recorder devices, PTT, gains, RX monitor |
|
||||
| **CW Keyer** | WinKeyer / Icom / TCI engine + macros |
|
||||
| **External services** | LoTW (TQSL), QRZ, eQSL, ClubLog, HRDLog, POTA |
|
||||
| **Email** | SMTP for eQSL-by-email |
|
||||
| **PowerGenius / Antenna Genius / Rotator / Ultrabeam** | station hardware |
|
||||
| **Audio** | DVK / recorder devices, PTT, gains and levels, RX monitor |
|
||||
| **CW Keyer** | engine (WinKeyer / Serial / Icom / Flex CWX / Yaesu / TCI), macros, ESM |
|
||||
| **External services** | LoTW (TQSL), QRZ, eQSL, ClubLog, HRDLog, POTA, Cloudlog/Wavelog |
|
||||
| **Email** | SMTP for eQSL-by-email and *Send log to F4BPO* |
|
||||
| **Amplifier / Tuner Genius / Antenna Genius / Rotator / Antenna** | station hardware ([[Amplifiers and Switches]] · [[Maps and Antennas]]) |
|
||||
| **Database** | settings DB + this profile's logbook, rename/relocate |
|
||||
| **Backup** | database + ADIF backup at shutdown |
|
||||
|
||||
## Appearance & language
|
||||
|
||||
Four themes (Warm light, Warm dark, Graphite dark, High contrast) plus **Auto**
|
||||
(follows the OS). Full **English / French** UI with a first-run flag chooser and
|
||||
a switcher in Settings → General.
|
||||
(follows the OS); fresh installs start dark. Full **English / French** UI with
|
||||
a first-run flag chooser and a switcher in Settings → General. **Ctrl + wheel**
|
||||
zooms the whole UI (remembered; Ctrl+0 resets). The window title bar is drawn
|
||||
by OpsLog — drag the top bar to move, double-click to maximise — and the window
|
||||
reopens on the screen it was closed on.
|
||||
|
||||
## Integrations (outbound UDP)
|
||||
## UDP integrations
|
||||
|
||||
Push the current frequency to **PstRotator**, radio info in **N1MM `RadioInfo`**
|
||||
format, or an **ADIF record on each logged QSO** — so external tools stay in sync.
|
||||
- **Inbound**: WSJT-X / MSHV / JTDX QSO logging and status (unicast or
|
||||
multicast), and DXHunter remote-call packets (fills the callsign, tunes the
|
||||
rig when the packet carries `<FREQ>`/`<MODE>`).
|
||||
- **Outbound**: the current frequency to **PstRotator**, radio info in **N1MM
|
||||
`RadioInfo`** format, or an **ADIF record on each logged QSO**.
|
||||
|
||||
## Other
|
||||
|
||||
- **Autostart** external programs (WSJT-X, JTAlert, rotator control…) at launch,
|
||||
skipping any already running.
|
||||
- **Update check** at startup (toggleable).
|
||||
- **Autostart** external programs (WSJT-X, JTAlert, rotator control…) at
|
||||
launch, skipping any already running.
|
||||
- **Update check** at startup, when opening Help → About, and every 5 minutes.
|
||||
A "What's new" summary appears on the first launch after each update.
|
||||
- **Help → Send log to F4BPO** (shown when SMTP is configured) e-mails the
|
||||
diagnostic logs straight to the developer.
|
||||
- **Anonymous usage telemetry** — a once-a-day heartbeat (random install ID +
|
||||
version + OS; no callsign or QSO data). Opt out in Preferences.
|
||||
|
||||
+50
-30
@@ -2,57 +2,77 @@
|
||||
|
||||
## Where's the log file?
|
||||
|
||||
OpsLog writes a diagnostic log. The path is shown in Settings (and can be opened
|
||||
from there). When reporting a problem, include the relevant lines — they're
|
||||
prefixed by subsystem (`Flex:`, `icom net:`, `icom scope`, `antgenius:`,
|
||||
`cluster:`, `qslmgr:log`, `audio:`…).
|
||||
OpsLog writes a diagnostic log. The path is shown in Settings (and can be
|
||||
opened from there). When reporting a problem, include the relevant lines —
|
||||
they're prefixed by subsystem (`cat:`, `Flex:`, `icom net:`, `antgenius:`,
|
||||
`cluster:`, `qslmgr:log`, `audio:`…). With an SMTP server configured, **Help →
|
||||
Send log to F4BPO** e-mails the logs straight to the developer.
|
||||
|
||||
A crash no longer leaves a blank window: the error is shown on screen,
|
||||
selectable, with a copy button.
|
||||
|
||||
## CAT
|
||||
|
||||
- **Radio not connecting** — check the backend fields (COM port + baud for USB;
|
||||
IP/credentials for network). OpsLog connects non-blocking, so it won't freeze —
|
||||
watch the CAT status.
|
||||
- **Icom: no CAT at all over USB** — the CI-V address must match the radio; pick
|
||||
your **model** from the dropdown (it sets the address) and set *CI-V USB Echo
|
||||
Back* **OFF** on the rig.
|
||||
- **Icom scope blank on an IC-7300** — this was a bug where the scope decoder was
|
||||
chosen from the configured CI-V address; it now uses the model detected from the
|
||||
CI-V ID, so a single-scope rig run at address `0x98` decodes correctly. Update
|
||||
to the latest build.
|
||||
- **Icom attenuator button does nothing** — the dB steps are model-specific; make
|
||||
sure the right model is selected (IC-7300 = 20 dB, IC-7610 = 6/12/18 dB).
|
||||
IP/credentials for network). The CAT log line carries the error explaining a
|
||||
disconnection.
|
||||
- **"Serial port busy" in the log** — another program holds the COM port
|
||||
(WSJT-X, MSHV, another logger configured for direct CAT). Only one program
|
||||
can own the port: let OpsLog hold it and point the others at **Hamlib NET
|
||||
rigctl 127.0.0.1:4532** (Settings → CAT → Share CAT). Two masters on one
|
||||
CI-V bus also cause garbled frames and erratic behaviour.
|
||||
- **The radio transmits as soon as OpsLog connects** — your interface reads DTR
|
||||
or RTS as PTT. Xiegu drops the lines automatically; on Yaesu and Kenwood
|
||||
enable the *lower DTR/RTS on connect* option. (It is off by default because
|
||||
other interfaces stop transmitting with the lines low.)
|
||||
- **WSJT-X won't key a Xiegu G90 through OmniRig** — a G90 ignores the CI-V PTT
|
||||
command. Use the native Xiegu backend (which keys RTS/DTR) + rigctl sharing,
|
||||
or set the PTT line inside OmniRig. See [[Kenwood and Xiegu]].
|
||||
- **OmniRig frequency frozen / wrong VFO** — set **VFO to read** (A or B) in
|
||||
the OmniRig backend settings; some rig files declare the wrong VFO.
|
||||
- **"OmniRig not found" with OmniRig running** — one of the two runs as
|
||||
administrator and the other doesn't. Start both the same way.
|
||||
- **Icom: no CAT at all over USB** — the CI-V address must match the radio;
|
||||
pick your **model** from the dropdown (it sets the address) and set *CI-V USB
|
||||
Echo Back* **OFF** on the rig.
|
||||
- **A radio that answers oddly** — enable the **protocol trace** (Settings →
|
||||
CAT): every frame to and from the rig lands in the log as hex, which turns a
|
||||
guess into a diagnosis.
|
||||
|
||||
## Remote Icom
|
||||
|
||||
- **Login rejected** → wrong Network User1 name/password.
|
||||
- **Can't bind local port** → the Icom **Remote Utility is still running** —
|
||||
close it.
|
||||
- **Link drops with the scope on** → receive-side retransmit should hold it; if
|
||||
not, check network quality and the `icom net:` log lines. See
|
||||
- **Frequency frozen after another program took the CI-V session** — OpsLog
|
||||
reconnects on its own; if it doesn't, check the `icom net:` log lines. See
|
||||
[[Remote Icom over the Internet]].
|
||||
|
||||
## FlexRadio
|
||||
|
||||
- **A control reads 0 / stale at startup** — some SmartSDR status fields use short
|
||||
names (e.g. the TX filter is reported as `lo`/`hi`, not `filter_low/high`). If a
|
||||
value never populates, capture the `Flex: … status` log line and report it.
|
||||
- **Antenna wrong band on a spot click** — fixed: `SetFrequency`/`SetMode` update
|
||||
the slice cache immediately so the panel and the Ultrabeam follow don't chase a
|
||||
stale frequency.
|
||||
- **A control reads 0 / stale at startup** — some SmartSDR status fields use
|
||||
short names. If a value never populates, capture the `Flex: … status` log
|
||||
line and report it.
|
||||
- **SmartSDR CAT keeps disconnecting while OpsLog is open** — fixed: OpsLog now
|
||||
binds only to the actual SmartSDR/Maestro GUI client. Update.
|
||||
- **"Interlock is preventing transmission" with a SteppIR** — see the tunable
|
||||
range setting in [[Maps and Antennas]].
|
||||
|
||||
## Multi-op live status PHP
|
||||
|
||||
`Connection refused` from the PHP page is almost always MySQL `bind-address =
|
||||
127.0.0.1` or a firewall/grant issue — see
|
||||
[[Multi-Operator Live Status]].
|
||||
127.0.0.1` or a firewall/grant issue — see [[Multi-Operator Live Status]].
|
||||
|
||||
## DVK / PTT
|
||||
|
||||
- **Test PTT does nothing on CAT/OmniRig** — the key is held ~1.5 s so the async
|
||||
OmniRig write is actually sent; if it still doesn't key, the rig's OmniRig
|
||||
profile may not expose CAT TX keying — use RTS/DTR or VOX.
|
||||
- **Test PTT does nothing on CAT/OmniRig** — the key is held ~1.5 s so the
|
||||
async OmniRig write is actually sent; if it still doesn't key, the rig's
|
||||
OmniRig profile may not expose CAT TX keying — use RTS/DTR or VOX.
|
||||
- **A voice message transmits almost nothing** — raise the message level
|
||||
(Settings → Audio) and check the rig's rear-input setting (FTDX10: SSB MOD
|
||||
SOURCE = REAR).
|
||||
|
||||
## Still stuck?
|
||||
|
||||
Open an issue with: what you did, what happened, your radio/model, and the
|
||||
relevant log lines.
|
||||
Open an issue (or *Send log to F4BPO*) with: what you did, what happened, your
|
||||
radio/model, and the relevant log lines.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Yaesu (native CAT)
|
||||
|
||||
A native backend talks to an **FTDX10 / FTDX101** (and close relatives) directly
|
||||
over the rig's USB/serial port — no OmniRig. Pick **Yaesu (USB)** in
|
||||
Settings → CAT: COM port + baud (FTDX10/101 default 38400).
|
||||
|
||||
Frequency, mode, VFO A/B (MAIN/SUB on the 101), split and PTT are covered, and
|
||||
OpsLog **follows the VFO you are actually on** — the displayed frequency, the
|
||||
logged frequency and the VFO a cluster spot tunes. Split is worked out from
|
||||
which VFO transmits, so simplex on the SUB receiver is not shown as split.
|
||||
|
||||
## The Yaesu console
|
||||
|
||||
With the native backend connected, a console tab (and a Main-view pane) gives:
|
||||
|
||||
- **S / PO / SWR meters** — power in watts on a curve measured against the rig's
|
||||
own display, SWR as the ratio itself, with peak-hold so they stay readable
|
||||
between CW words or SSB syllables.
|
||||
- **Band and mode buttons** — CW, RTTY, DIGI and PSK show their sideband; click
|
||||
the active button again to switch it.
|
||||
- AF / RF / squelch, AGC, IPO–AMP1–AMP2, 6/12/18 dB attenuator, NB, DNR, narrow
|
||||
filter, power, mic gain, VOX, split and **ATU tune**.
|
||||
- **SPLIT** places the transmit VFO up 1 kHz on CW/data, up 5 kHz on phone.
|
||||
- In CW the mic gain and VOX give way to a **CW card**: keyer speed, break-in
|
||||
and ZIN (zero-in).
|
||||
|
||||
Sliders only respond to the mouse wheel after you click them, so scrolling the
|
||||
panel can't change the transmit power by accident.
|
||||
|
||||
## CW with the rig's own keyer
|
||||
|
||||
A CW engine sends your macros with the radio's internal keyer (CAT `KY`) — no
|
||||
WinKeyer, no second cable. Pick **Yaesu (rig keyer)** in Settings → CW keyer.
|
||||
|
||||
- Works on the **FTDX101 / FT-991A / FT-710** family.
|
||||
- An **FTDX10 does not accept it** (`KY` is refused, `DAKY` doesn't help) —
|
||||
OpsLog says so and points to the **serial DTR keyer** on the rig's second COM
|
||||
port (the *Serial port* CW engine), which works. See [[Audio and Keyers]].
|
||||
- CW speed is one setting everywhere: the console slider, the CW panel and the
|
||||
rig's front panel stay in agreement.
|
||||
|
||||
## Notes
|
||||
|
||||
- A Yaesu that is switched off is reported as such (opening the port is not
|
||||
"connected").
|
||||
- If your interface goes into transmit the moment OpsLog connects, enable the
|
||||
**lower DTR/RTS on connect** option — see [[CAT Control]].
|
||||
@@ -16,6 +16,8 @@
|
||||
- [[FlexRadio]]
|
||||
- [[Icom]]
|
||||
- [[Remote Icom over the Internet]]
|
||||
- [[Yaesu]]
|
||||
- [[Kenwood and Xiegu]]
|
||||
|
||||
**Operating**
|
||||
- [[DX Cluster and Spots]]
|
||||
|
||||
Reference in New Issue
Block a user