Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
712a124081 | ||
|
|
08a932c9c1 | ||
|
|
fee2303625 | ||
|
|
4d5c5c3eb3 | ||
|
|
d322ea9a07 | ||
|
|
75c02ea2a0 | ||
|
|
ef84b04c99 | ||
|
|
fbd6cf400f | ||
|
|
b16cb973f2 | ||
|
|
da886de189 | ||
|
|
7759e09b4e | ||
|
|
a8990ff29d | ||
|
|
e3b810b4ff | ||
|
|
337392bb6d | ||
|
|
d98bb2e929 | ||
|
|
3ab6b95ff8 | ||
|
|
b0da6a3d14 | ||
|
|
2d7469a7f7 | ||
|
|
775f411606 | ||
|
|
6501e97895 | ||
|
|
f3807c21ba | ||
|
|
d536c39ef5 | ||
|
|
284a7a18c7 | ||
|
|
482f81fe45 | ||
|
|
5d7a9a9562 | ||
|
|
a036120871 | ||
|
|
ccceab9d02 | ||
|
|
35db1440e4 | ||
|
|
e3aabc06a4 | ||
|
|
ab9d0bfe0f | ||
|
|
a3fd32ec91 | ||
|
|
c297f91ca8 | ||
|
|
ee1f9ccf35 | ||
|
|
6fb7f06375 |
@@ -93,6 +93,8 @@ Where two devices are interchangeable (Ultrabeam / SteppIR), `app.go` defines a
|
|||||||
|
|
||||||
**Changelog is mandatory.** Every user-visible change gets an entry in `changelog.json`, **in both `en` and `fr`**, in the same session as the change. Keep entries to one or two sentences — rationale belongs in the commit message. New work goes under the next version number; the release script bumps the version constants.
|
**Changelog is mandatory.** Every user-visible change gets an entry in `changelog.json`, **in both `en` and `fr`**, in the same session as the change. Keep entries to one or two sentences — rationale belongs in the commit message. New work goes under the next version number; the release script bumps the version constants.
|
||||||
|
|
||||||
|
**Append new entries at the BOTTOM of a version block**, never at the top. A block is read top to bottom, so a feature must appear before the fixes made to it — prepending meant an operator read "the Yaesu meters are corrected" several entries before learning a Yaesu console existed at all.
|
||||||
|
|
||||||
**Version lives in two places** and must stay in lockstep: `appVersion` in `telemetry.go` and `APP_VERSION` in `frontend/src/version.ts`.
|
**Version lives in two places** and must stay in lockstep: `appVersion` in `telemetry.go` and `APP_VERSION` in `frontend/src/version.ts`.
|
||||||
|
|
||||||
**Bilingual UI.** Every user-visible string goes through `t()` from `frontend/src/lib/i18n.tsx` (~700 keys), with both English and French provided. No hardcoded display strings.
|
**Bilingual UI.** Every user-visible string goes through `t()` from `frontend/src/lib/i18n.tsx` (~700 keys), with both English and French provided. No hardcoded display strings.
|
||||||
|
|||||||
+230
-34
@@ -1,46 +1,242 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"en": [
|
||||||
|
"The Antenna Genius and Tuner Genius settings entries carry a small 4O3A mark, so the panels driving that hardware are recognisable at a glance in the sidebar.",
|
||||||
|
"The database shown in the status bar is the LOGBOOK file. On a local SQLite setup it named the settings database instead, so anyone checking where their QSOs live — or which file to back up — was pointed at the wrong one.",
|
||||||
|
"Yaesu: the mode follows the VFO you are on. A spot clicked while working the SUB receiver tuned the sub VFO but set the mode on MAIN, leaving the VFO in use on its old mode; the console read main mode too.",
|
||||||
|
"Rotator control now covers any GS-232A controller, ERC (Easy Rotor Control) included: the serial speed is selectable and the entry is named after the protocol rather than one device. Set the ERC to GS-232 emulation, not Hy-Gain DCU-1.",
|
||||||
|
"Native Kenwood CAT: a sixth backend talks straight to a TS-590, TS-890, TS-990 or TS-2000 over its serial port — frequency, mode, VFO, split and PTT — with no OmniRig in between. Elecraft K3/K4 speak the same dialect. Pick \"Kenwood (native)\" in Settings → CAT.",
|
||||||
|
"Icom and Xiegu: a CI-V frame read at the wrong offset no longer turns into a frequency. Values such as 16445550000 Hz appeared in the status bar while the rig sat on 145.550 MHz — the decoder treated non-decimal bytes as digits. Such frames are now refused and the last good reading stands.",
|
||||||
|
"Settings → CAT can log the CI-V protocol: every frame to and from an Icom or Xiegu, as hex. For reporting a radio that answers oddly — a button that does the wrong thing, a frequency that jumps — where a description alone leaves the cause to guesswork.",
|
||||||
|
"A crash in the window no longer leaves a blank screen. The error is written to the app log and shown on screen, selectable, with a button to copy it and one to reload — so a fault can be reported with its cause instead of a description of an empty window.",
|
||||||
|
"ADIF import can move fields as it reads them. Contest software stores the exchange where its own module keeps it — the RSGB IOTA contest exports the island reference in STATE, which imports as a US state and leaves the IOTA award empty. Add a STATE → IOTA row in the import dialog and it lands where the award looks.",
|
||||||
|
"The recent-QSO grid no longer offers per-column filters. They only ever searched the rows on screen, so an empty result looked like a missing QSO when the log simply held it further back — use the advanced filter, which queries the whole logbook. Column headers gain the width back."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les entrées de réglages Antenna Genius et Tuner Genius portent une petite marque 4O3A : les panneaux pilotant ce matériel se repèrent d'un coup d'œil dans le menu.",
|
||||||
|
"La base affichée dans la barre d'état est bien le fichier du CARNET. En SQLite local, elle désignait la base des réglages : qui vérifiait où résident ses QSO — ou quel fichier sauvegarder — était orienté vers le mauvais.",
|
||||||
|
"Yaesu : le mode suit le VFO utilisé. Un spot cliqué en travaillant sur le récepteur SUB accordait bien le VFO secondaire mais réglait le mode sur le MAIN, laissant le VFO en service sur son ancien mode ; la console lisait également le mode du principal.",
|
||||||
|
"Le contrôle de rotator couvre désormais tout contrôleur GS-232A, ERC (Easy Rotor Control) compris : la vitesse du port série est sélectionnable et l'entrée porte le nom du protocole plutôt que celui d'un seul appareil. Réglez l'ERC sur l'émulation GS-232, pas Hy-Gain DCU-1.",
|
||||||
|
"CAT Kenwood natif : un sixième backend parle directement à un TS-590, TS-890, TS-990 ou TS-2000 par son port série — fréquence, mode, VFO, split et PTT — sans OmniRig. Les Elecraft K3/K4 parlent le même dialecte. À choisir sous « Kenwood (natif) » dans Réglages → CAT.",
|
||||||
|
"Icom et Xiegu : une trame CI-V lue au mauvais décalage ne se transforme plus en fréquence. Des valeurs comme 16445550000 Hz apparaissaient dans la barre d'état alors que la radio était sur 145,550 MHz — le décodeur prenait des octets non décimaux pour des chiffres. Ces trames sont désormais refusées et la dernière lecture valable est conservée.",
|
||||||
|
"Réglages → CAT permet de journaliser le protocole CI-V : chaque trame échangée avec un Icom ou un Xiegu, en hexadécimal. Pour signaler une radio qui répond de travers — un bouton qui fait autre chose, une fréquence qui saute — là où une description seule laisse la cause à la devinette.",
|
||||||
|
"Un plantage de la fenêtre ne laisse plus un écran vide. L'erreur est écrite dans le journal et affichée à l'écran, sélectionnable, avec un bouton pour la copier et un pour recharger — de quoi signaler un défaut avec sa cause plutôt qu'une description de fenêtre vide.",
|
||||||
|
"L'import ADIF peut déplacer des champs à la lecture. Les logiciels de concours rangent l'échange là où leur module le garde — le contest IOTA de la RSGB exporte la référence d'île dans STATE, importée comme un état américain, et le diplôme IOTA reste vide. Une ligne STATE → IOTA dans la fenêtre d'import et la référence arrive là où le diplôme la cherche.",
|
||||||
|
"La grille des QSO récents ne propose plus de filtres par colonne. Ils ne cherchaient que dans les lignes affichées : un résultat vide ressemblait à un QSO manquant alors que le journal le contenait plus loin — le filtre avancé, lui, interroge tout le journal. Les en-têtes de colonne récupèrent la place."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.22.1",
|
||||||
|
"date": "2026-07-29",
|
||||||
|
"en": [
|
||||||
|
"Clicking a cluster spot while listening on the SUB VFO no longer pulls the radio back to MAIN. The command used to tune sets the main VFO by definition; on SUB the sub VFO is now written directly and the VFO selection is left alone.",
|
||||||
|
"Split showed the opposite of the radio on some Yaesus. Where the rig reports which VFO transmits rather than a split flag, that has to be compared with the VFO you are listening to — so it read backwards for an operator working on SUB and correctly for one on MAIN. Setting split had the same fault.",
|
||||||
|
"A shared MySQL logbook could fail to build its schema, leaving OpsLog on the local SQLite database with a message that only said the connection failed. Column declarations that are quoted, or share a line with others, escaped the SQLite-to-MySQL type conversion — so MySQL refused an index and a default value. A database already left in that state is now repaired at startup instead of failing at every launch.",
|
||||||
|
"Yaesu: OpsLog now follows the VFO you are actually on — the displayed frequency, and the VFO a cluster spot tunes. An FTDX101 answers the receive-VFO query with two digits where an FTDX10 uses one, and the wrong one was read.",
|
||||||
|
"Clicking a spot in DXHunter can now tune the radio: a remote-call UDP packet carrying <FREQ> and <MODE> sets the frequency and mode on whichever CAT backend is active, as well as filling the callsign. A packet without them still just fills the callsign.",
|
||||||
|
"The ADIF import option that fills your station fields from your profile also stamps your default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com) on the ones the file leaves empty — it always did, but nothing said so, and a WSJT-X log carries almost none. Existing confirmations are never overwritten.",
|
||||||
|
"QRZ.com confirmation download marked far too many QSOs as confirmed: it accepted the paper-QSL received flag, which you uploaded to QRZ yourself, as if it were a QRZ confirmation. Only QRZ own confirmation now counts — check the QRZ received column after your next download, as earlier ones may have set it wrongly.",
|
||||||
|
"The Recent QSOs counter labels each of its three numbers — shown, matching the filter, and in the log — with thousands separators. \"Showing 10000 of 23683 matches · 28648 total\" read as if the filter had found more QSOs than the log holds.",
|
||||||
|
"Switching the CAT backend back to a Yaesu no longer risks leaving the serial port held. Each reconnect opened a new handle without closing the previous one, and Windows opens a serial port exclusively, so the next attempt could fail with \"port busy\".",
|
||||||
|
"A Yaesu that is switched off is reported as such. Opening the serial port used to be logged as \"connected\", with the model name left over from the previous session, so a radio that was simply powered down looked like a software fault.",
|
||||||
|
"Yaesu: moving RX and TX together to the SUB receiver is no longer shown as split. That is simplex on the sub VFO, but the rig reports its split flag anyway, so OpsLog now works split out from which VFO transmits and which receives.",
|
||||||
|
"Auto-call CW now waits the gap you set AFTER the message, not from the moment it starts. With a WinKeyer or serial keyer the wait could end before the call had finished, so a 6 s gap gave about 2 s of silence after a 4 s CQ."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Cliquer sur un spot du cluster en écoutant sur le VFO SUB ne ramène plus la radio sur le VFO principal. La commande utilisée pour s'accorder agit par définition sur le VFO principal ; sur SUB, c'est désormais le VFO secondaire qui est écrit, sans toucher à la sélection.",
|
||||||
|
"Le split affichait l'inverse de la radio sur certains Yaesu. Quand la radio indique quel VFO émet plutôt qu'un indicateur de split, il faut le comparer au VFO écouté — l'affichage était donc inversé pour un opérateur travaillant sur SUB et correct pour un autre sur MAIN. Le réglage du split souffrait du même défaut.",
|
||||||
|
"Un carnet MySQL partagé pouvait échouer à créer son schéma, laissant OpsLog sur la base SQLite locale avec un message indiquant seulement l'échec de la connexion. Les déclarations de colonnes entre guillemets, ou partageant une ligne avec d'autres, échappaient à la conversion de types SQLite vers MySQL — d'où le refus d'un index et d'une valeur par défaut. Une base déjà dans cet état est désormais réparée au démarrage au lieu d'échouer à chaque lancement.",
|
||||||
|
"Yaesu : OpsLog suit désormais le VFO réellement utilisé — la fréquence affichée, et le VFO qu'un spot du cluster accorde. Un FTDX101 répond à la question du VFO de réception avec deux chiffres là où un FTDX10 n'en donne qu'un, et c'est le mauvais qui était lu.",
|
||||||
|
"Cliquer un spot dans DXHunter peut désormais accorder la radio : un paquet UDP remote_call contenant <FREQ> et <MODE> règle la fréquence et le mode sur le backend CAT actif, en plus de renseigner l'indicatif. Un paquet sans ces balises se contente, comme avant, de renseigner l'indicatif.",
|
||||||
|
"L'option d'import ADIF qui remplit vos champs station depuis votre profil applique aussi vos statuts de confirmation par défaut (QSL papier, LoTW, eQSL, Club Log, HRDLog, QRZ.com) sur ceux que le fichier laisse vides — c'était déjà le cas, mais rien ne le disait, et un log WSJT-X n'en contient pratiquement aucun. Les confirmations existantes ne sont jamais écrasées.",
|
||||||
|
"Le téléchargement des confirmations QRZ.com marquait beaucoup trop de QSO comme confirmés : il acceptait l'indicateur de QSL papier reçue — que vous avez vous-même envoyé à QRZ — comme une confirmation QRZ. Seule la confirmation propre à QRZ compte désormais ; vérifiez la colonne QRZ.com reçu après votre prochain téléchargement, les précédents ayant pu la remplir à tort.",
|
||||||
|
"Le compteur des QSO récents nomme chacun de ses trois nombres — affichés, correspondant au filtre, et présents dans le log — avec des séparateurs de milliers. « Showing 10000 of 23683 matches · 28648 total » se lisait comme si le filtre trouvait plus de QSO que le log n'en contient.",
|
||||||
|
"Repasser le backend CAT sur un Yaesu ne risque plus de laisser le port série occupé. Chaque reconnexion ouvrait une nouvelle poignée sans fermer la précédente, et Windows ouvre un port série en exclusivité : la tentative suivante pouvait échouer avec « port occupé ».",
|
||||||
|
"Un Yaesu éteint est signalé comme tel. L'ouverture du port série était journalisée comme « connecté », avec le nom de modèle hérité de la session précédente : une radio simplement hors tension ressemblait à un défaut du logiciel.",
|
||||||
|
"Yaesu : passer RX et TX ensemble sur le récepteur SUB n'est plus affiché comme un split. C'est du simplex sur le VFO secondaire, mais la radio lève quand même son indicateur de split ; OpsLog le déduit désormais du VFO qui émet et de celui qui reçoit.",
|
||||||
|
"L'appel automatique en CW attend désormais l'intervalle réglé APRÈS le message, et non depuis son début. Avec un WinKeyer ou un keyer série, l'attente pouvait se terminer avant la fin de l'appel : un intervalle de 6 s ne laissait qu'environ 2 s de silence après un CQ de 4 s."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.22.0",
|
"version": "0.22.0",
|
||||||
"date": "2026-07-28",
|
"date": "2026-07-28",
|
||||||
"en": [
|
"en": [
|
||||||
"WinKeyer 2: keying is fixed. The dit/dah ratio was sent on the command that sets KEY COMPENSATION, so every element got 50 ms of extra key-down — at 25 wpm a dit is 48 ms — and the keyer sent one element then stalled. The compensation is also cleared, since a keyer already set that way keeps it.",
|
"CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes.",
|
||||||
"The voice keyer PTT list now names the Yaesu and Xiegu backends too — with a native backend selected it showed no CAT name, which read as \"CAT PTT is not available for my radio\".",
|
|
||||||
"Voice keyer: a level control for the messages sent to the radio (Settings → Audio). They went out exactly as recorded, so a quietly recorded microphone drove the rig quietly with nothing in OpsLog to fix it. Play previews at the same level, and the hint names the rig setting to check when it transmits almost nothing (on an FTDX10, SSB MOD SOURCE = REAR).",
|
|
||||||
"Yaesu console meters are right: power now reads the meter that follows the RF (in watts, on a curve measured against the rig own display, not the power setting) and SWR shows the ratio itself. Both were reading the wrong index — the SWR bar sat near 80 on a perfect match. The bars also hold their peak for a moment before falling back, so they stay readable through the gaps between words in a CQ, in CW as in SSB.",
|
|
||||||
"CW speed is now one setting wherever you change it: the Yaesu console slider and the CW panel drive the keyer that is actually sending, and the rig own keyer follows so its front panel agrees.",
|
|
||||||
"Antenna Genius: port A no longer jumps onto port B antenna a few seconds after a change. Only one port can hold the transmit antenna, so the switch reports the same one on both — the display now follows each port own receive antenna.",
|
|
||||||
"CW through the Yaesu keyer: a fifth CW engine sends your macros with the radio own keyer (CAT \"KY\") — no WinKeyer, no second cable. Pick \"Yaesu (rig keyer)\" in Settings → CW keyer. For the FTDX101 / FT-991A / FT-710 family: an FTDX10 does not accept it (DAKY included), and OpsLog says so, pointing to the serial DTR keyer on the rig second COM port, which works.",
|
|
||||||
"The Yaesu console follows the mode: in CW the microphone gain and VOX disappear and a CW card takes their place with keyer speed, break-in and ZIN (zero-in).",
|
|
||||||
"Native Xiegu CAT (G90, X6100, X6200, X5105): a new backend talks CI-V straight to the radio — frequency, mode, split and PTT. Pick \"Xiegu (native CI-V)\" in Settings → CAT. Untested on a radio so far, feedback welcome.",
|
|
||||||
"CAT sharing: OpsLog can now serve its rig connection to other programs (Settings → CAT → Share CAT). WSJT-X, JTDX, MSHV or Log4OM connect with rig model \"Hamlib NET rigctl\" at 127.0.0.1:4532 — needed because a native CAT backend holds the radio serial port on its own. Works with every backend, and with both Hamlib command dialects (JTDX names the VFO, MSHV does not).",
|
|
||||||
"Native Yaesu CAT: a new backend talks to an FTDX10/FTDX101 directly over its serial port, without OmniRig — frequency, mode, VFO A/B, split and PTT. Pick \"Yaesu (native CAT)\" in Settings → CAT. First release, feedback welcome.",
|
|
||||||
"Changing band from OpsLog now updates the displayed frequency straight away. The rig moved, but its answer arrived inside the short grace window that protects what you are typing and was discarded — so the frequency stayed on the old band until you nudged the VFO.",
|
|
||||||
"Bulk edit can now set the contacted station gridsquare, so a batch of QSOs imported without a locator can be corrected in one go.",
|
|
||||||
"Filter builder: a confirmation date typed into a condition showed an empty box. The value was stored correctly, only the calendar field failed to display it.",
|
|
||||||
"The Icom model list gains the IC-7300MKII (CI-V B6), plus the IC-7100, IC-7410, IC-7600 and IC-7851. The IC-7700 and IC-7800 were listed at the IC-7100's and IC-7410's addresses, so picking them set an address the rig never answers on.",
|
|
||||||
"Icom over the LAN: the frequency no longer stays frozen after another program (WSJT-X through OmniRig, the Remote Utility) takes the CI-V session. The rig kept answering pings while sending nothing, so OpsLog showed the last known frequency until it was restarted; it now reconnects on its own.",
|
|
||||||
"The offline FCC (ULS) database now answers while you type a US callsign, filling state, county and a 6-character grid. It was only applied when the QSO was saved, so without a QRZ.com or HamQTH account you saw nothing but the country and a coarse grid. Online lookups still win — ULS only fills what is blank.",
|
"The offline FCC (ULS) database now answers while you type a US callsign, filling state, county and a 6-character grid. It was only applied when the QSO was saved, so without a QRZ.com or HamQTH account you saw nothing but the country and a coarse grid. Online lookups still win — ULS only fills what is blank.",
|
||||||
"CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes."
|
"Icom over the LAN: the frequency no longer stays frozen after another program (WSJT-X through OmniRig, the Remote Utility) takes the CI-V session. The rig kept answering pings while sending nothing, so OpsLog showed the last known frequency until it was restarted; it now reconnects on its own.",
|
||||||
|
"The Icom model list gains the IC-7300MKII (CI-V B6), plus the IC-7100, IC-7410, IC-7600 and IC-7851. The IC-7700 and IC-7800 were listed at the IC-7100's and IC-7410's addresses, so picking them set an address the rig never answers on.",
|
||||||
|
"Filter builder: a confirmation date typed into a condition showed an empty box. The value was stored correctly, only the calendar field failed to display it.",
|
||||||
|
"Bulk edit can now set the contacted station gridsquare, so a batch of QSOs imported without a locator can be corrected in one go.",
|
||||||
|
"Changing band from OpsLog now updates the displayed frequency straight away. The rig moved, but its answer arrived inside the short grace window that protects what you are typing and was discarded — so the frequency stayed on the old band until you nudged the VFO.",
|
||||||
|
"Native Yaesu CAT: a new backend talks to an FTDX10/FTDX101 directly over its serial port, without OmniRig — frequency, mode, VFO A/B, split and PTT. Pick \"Yaesu (native CAT)\" in Settings → CAT. First release, feedback welcome.",
|
||||||
|
"Yaesu console: with the native Yaesu backend, a console tab and a Main-view pane give S/PO/SWR meters, band and mode buttons, AF/RF/squelch, AGC, IPO-AMP1-AMP2, a 6/12/18 dB attenuator, NB, DNR, narrow filter, power, mic gain, VOX, split and ATU tune. CW, RTTY, DIGI and PSK show their sideband — click an active button again to switch it. Pressing SPLIT places the transmit VFO up 1 kHz on CW and data, up 5 kHz on phone, and shows the transmit frequency.",
|
||||||
|
"CAT sharing: OpsLog can now serve its rig connection to other programs (Settings → CAT → Share CAT). WSJT-X, JTDX, MSHV or Log4OM connect with rig model \"Hamlib NET rigctl\" at 127.0.0.1:4532 — needed because a native CAT backend holds the radio serial port on its own. Works with every backend, and with both Hamlib command dialects (JTDX names the VFO, MSHV does not).",
|
||||||
|
"Native Xiegu CAT (G90, X6100, X6200, X5105): a new backend talks CI-V straight to the radio — frequency, mode, split and PTT. Pick \"Xiegu (native CI-V)\" in Settings → CAT. Untested on a radio so far, feedback welcome.",
|
||||||
|
"The Yaesu console follows the mode: in CW the microphone gain and VOX disappear and a CW card takes their place with keyer speed, break-in and ZIN (zero-in).",
|
||||||
|
"CW through the Yaesu keyer: a fifth CW engine sends your macros with the radio own keyer (CAT \"KY\") — no WinKeyer, no second cable. Pick \"Yaesu (rig keyer)\" in Settings → CW keyer. For the FTDX101 / FT-991A / FT-710 family: an FTDX10 does not accept it (DAKY included), and OpsLog says so, pointing to the serial DTR keyer on the rig second COM port, which works.",
|
||||||
|
"Antenna Genius: port A no longer jumps onto port B antenna a few seconds after a change. Only one port can hold the transmit antenna, so the switch reports the same one on both — the display now follows each port own receive antenna.",
|
||||||
|
"CW speed is now one setting wherever you change it: the Yaesu console slider and the CW panel drive the keyer that is actually sending, and the rig own keyer follows so its front panel agrees.",
|
||||||
|
"Yaesu console meters are right: power now reads the meter that follows the RF (in watts, on a curve measured against the rig own display, not the power setting) and SWR shows the ratio itself. Both were reading the wrong index — the SWR bar sat near 80 on a perfect match. The bars also hold their peak for a moment before falling back, so they stay readable through the gaps between words in a CQ, in CW as in SSB.",
|
||||||
|
"Voice keyer: a level control for the messages sent to the radio (Settings → Audio). They went out exactly as recorded, so a quietly recorded microphone drove the rig quietly with nothing in OpsLog to fix it. Play previews at the same level, and the hint names the rig setting to check when it transmits almost nothing (on an FTDX10, SSB MOD SOURCE = REAR).",
|
||||||
|
"The voice keyer PTT list now names the Yaesu and Xiegu backends too — with a native backend selected it showed no CAT name, which read as \"CAT PTT is not available for my radio\".",
|
||||||
|
"WinKeyer 2: keying is fixed. The dit/dah ratio was sent on the command that sets KEY COMPENSATION, so every element got 50 ms of extra key-down — at 25 wpm a dit is 48 ms — and the keyer sent one element then stalled. The compensation is also cleared, since a keyer already set that way keeps it.",
|
||||||
|
"ADIF import accepts a file with no header. Records pasted straight out of another log — a few lines lifted from wsjtx_log.adi into a new .adi — imported as \"0 imported, 0 ignored, 0 total\", which reads as an empty file rather than a missing tag."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"WinKeyer 2 : la manipulation est réparée. Le rapport point/trait était envoyé sur la commande qui règle la COMPENSATION DE MANIPULATION : chaque élément recevait donc 50 ms de fermeture supplémentaire — à 25 mots/minute un point dure 48 ms — et le keyer envoyait un élément puis se bloquait. La compensation est aussi remise à zéro, car un keyer déjà réglé ainsi la conserve.",
|
"Les zones CQ et ITU corrigées à la main dans votre profil restent corrigées. Elles proviennent de cty.dat, qui donne les zones de l'entité entière — dans un pays qui en couvre plusieurs, la valeur automatique est fausse et revenait à chaque redémarrage. Elles ne se remplissent désormais que si le champ est vide, et se recalculent quand l'indicatif ou le locator change.",
|
||||||
"La liste PTT du voice keyer nomme aussi les backends Yaesu et Xiegu — avec un backend natif sélectionné, aucun nom de CAT n'apparaissait, ce qui se lisait « il n'y a pas de PTT CAT pour ma radio ».",
|
|
||||||
"Voice keyer : un réglage de niveau pour les messages envoyés à la radio (Réglages → Audio). Ils partaient exactement tels qu'enregistrés : un micro capté faiblement attaquait donc la radio faiblement, sans rien dans OpsLog pour y remédier. Le bouton Lire fait entendre ce même niveau, et l'aide indique le réglage radio à vérifier quand elle n'émet presque rien (sur un FTDX10, SSB MOD SOURCE = REAR).",
|
|
||||||
"Les mesures de la console Yaesu sont justes : la puissance lit l'instrument qui suit la HF (en watts, sur une courbe relevée face à l'affichage de la radio, et non le réglage de puissance) et le ROS affiche le rapport lui-même. Les deux lisaient le mauvais index — la barre de ROS restait vers 80 sur une antenne parfaite. Les barres retiennent aussi leur crête un instant avant de redescendre : elles restent lisibles pendant les silences entre les mots d’un CQ, en CW comme en phonie.",
|
|
||||||
"La vitesse CW est désormais un réglage unique où que vous la changiez : le curseur de la console Yaesu et le panneau CW pilotent le manipulateur qui émet réellement, et le keyer interne de la radio suit pour que sa façade affiche la même valeur.",
|
|
||||||
"Antenna Genius : le port A ne bascule plus sur l'antenne du port B quelques secondes après un changement. Un seul port peut porter l'antenne d'émission, le switch renvoie donc la même sur les deux — l'affichage suit désormais l'antenne de réception propre à chaque port.",
|
|
||||||
"CW par le keyer Yaesu : un cinquième moteur CW envoie vos macros avec le keyer de la radio (CAT « KY ») — sans WinKeyer ni second câble. À choisir dans Réglages → Keyer CW sous « Yaesu (keyer de la radio) ». Pour la famille FTDX101 / FT-991A / FT-710 : un FTDX10 ne l'accepte pas (DAKY compris), et OpsLog le dit en renvoyant vers le keyer série DTR sur son second port COM, qui fonctionne.",
|
|
||||||
"La console Yaesu suit le mode : en CW, le gain micro et le VOX disparaissent au profit d'une carte CW avec la vitesse du manipulateur, le break-in et ZIN (zéro-in).",
|
|
||||||
"CAT Xiegu natif (G90, X6100, X6200, X5105) : un nouveau backend dialogue en CI-V directement avec la radio — fréquence, mode, split et PTT. À choisir dans Réglages → CAT sous « Xiegu (CI-V natif) ». Pas encore testé sur une radio, retours bienvenus.",
|
|
||||||
"Partage du CAT : OpsLog peut désormais servir sa liaison radio aux autres logiciels (Réglages → CAT → Partager le CAT). WSJT-X, JTDX, MSHV ou Log4OM s'y connectent avec le modèle « Hamlib NET rigctl » sur 127.0.0.1:4532 — nécessaire car un backend CAT natif occupe seul le port série. Fonctionne avec tous les backends, et avec les deux dialectes Hamlib (JTDX nomme le VFO, MSHV non).",
|
|
||||||
"CAT Yaesu natif : un nouveau backend dialogue directement avec un FTDX10/FTDX101 par son port série, sans OmniRig — fréquence, mode, VFO A/B, split et PTT. À choisir dans Réglages → CAT sous « Yaesu (CAT natif) ». Première version, retours bienvenus.",
|
|
||||||
"Changer de bande depuis OpsLog met désormais la fréquence affichée à jour immédiatement. La radio se déplaçait bien, mais sa réponse arrivait pendant le court délai qui protège votre saisie et était ignorée — la fréquence restait donc sur l'ancienne bande jusqu'à ce qu'on touche le VFO.",
|
|
||||||
"L'édition groupée peut désormais définir le locator de la station contactée : un lot de QSO importés sans locator se corrige en une fois.",
|
|
||||||
"Constructeur de filtres : une date de confirmation saisie dans une condition s'affichait dans une case vide. La valeur était bien enregistrée, seul le champ calendrier ne la montrait pas.",
|
|
||||||
"La liste des modèles Icom gagne l'IC-7300MKII (CI-V B6), ainsi que les IC-7100, IC-7410, IC-7600 et IC-7851. Les IC-7700 et IC-7800 y figuraient avec les adresses des IC-7100 et IC-7410 : les choisir réglait une adresse sur laquelle la radio ne répond jamais.",
|
|
||||||
"Icom via le réseau : la fréquence ne reste plus figée après qu'un autre programme (WSJT-X via OmniRig, la Remote Utility) a pris la session CI-V. La radio continuait de répondre aux pings sans rien émettre, si bien qu'OpsLog affichait la dernière fréquence connue jusqu'à son redémarrage ; il se reconnecte désormais tout seul.",
|
|
||||||
"La base FCC (ULS) hors ligne répond désormais pendant la saisie d'un indicatif américain, en remplissant l'état, le comté et un locator 6 caractères. Elle n'était appliquée qu'à l'enregistrement du QSO : sans compte QRZ.com ou HamQTH, vous ne voyiez que le pays et un locator approximatif. Les recherches en ligne restent prioritaires — l'ULS ne comble que ce qui est vide.",
|
"La base FCC (ULS) hors ligne répond désormais pendant la saisie d'un indicatif américain, en remplissant l'état, le comté et un locator 6 caractères. Elle n'était appliquée qu'à l'enregistrement du QSO : sans compte QRZ.com ou HamQTH, vous ne voyiez que le pays et un locator approximatif. Les recherches en ligne restent prioritaires — l'ULS ne comble que ce qui est vide.",
|
||||||
"Les zones CQ et ITU corrigées à la main dans votre profil restent corrigées. Elles proviennent de cty.dat, qui donne les zones de l'entité entière — dans un pays qui en couvre plusieurs, la valeur automatique est fausse et revenait à chaque redémarrage. Elles ne se remplissent désormais que si le champ est vide, et se recalculent quand l'indicatif ou le locator change."
|
"Icom via le réseau : la fréquence ne reste plus figée après qu'un autre programme (WSJT-X via OmniRig, la Remote Utility) a pris la session CI-V. La radio continuait de répondre aux pings sans rien émettre, si bien qu'OpsLog affichait la dernière fréquence connue jusqu'à son redémarrage ; il se reconnecte désormais tout seul.",
|
||||||
|
"La liste des modèles Icom gagne l'IC-7300MKII (CI-V B6), ainsi que les IC-7100, IC-7410, IC-7600 et IC-7851. Les IC-7700 et IC-7800 y figuraient avec les adresses des IC-7100 et IC-7410 : les choisir réglait une adresse sur laquelle la radio ne répond jamais.",
|
||||||
|
"Constructeur de filtres : une date de confirmation saisie dans une condition s'affichait dans une case vide. La valeur était bien enregistrée, seul le champ calendrier ne la montrait pas.",
|
||||||
|
"L'édition groupée peut désormais définir le locator de la station contactée : un lot de QSO importés sans locator se corrige en une fois.",
|
||||||
|
"Changer de bande depuis OpsLog met désormais la fréquence affichée à jour immédiatement. La radio se déplaçait bien, mais sa réponse arrivait pendant le court délai qui protège votre saisie et était ignorée — la fréquence restait donc sur l'ancienne bande jusqu'à ce qu'on touche le VFO.",
|
||||||
|
"CAT Yaesu natif : un nouveau backend dialogue directement avec un FTDX10/FTDX101 par son port série, sans OmniRig — fréquence, mode, VFO A/B, split et PTT. À choisir dans Réglages → CAT sous « Yaesu (CAT natif) ». Première version, retours bienvenus.",
|
||||||
|
"Console Yaesu : avec le backend Yaesu natif, un onglet console et un volet de la vue principale offrent les mesures S/PO/ROS, les boutons de bande et de mode, AF/RF/squelch, AGC, IPO-AMP1-AMP2, un atténuateur 6/12/18 dB, NB, DNR, filtre étroit, puissance, gain micro, VOX, split et accord d'antenne. CW, RTTY, DIGI et PSK affichent leur bande latérale — recliquez sur le bouton actif pour en changer. Appuyer sur SPLIT place le VFO d'émission à +1 kHz en CW et numérique, +5 kHz en phonie, et affiche la fréquence d'émission.",
|
||||||
|
"Partage du CAT : OpsLog peut désormais servir sa liaison radio aux autres logiciels (Réglages → CAT → Partager le CAT). WSJT-X, JTDX, MSHV ou Log4OM s'y connectent avec le modèle « Hamlib NET rigctl » sur 127.0.0.1:4532 — nécessaire car un backend CAT natif occupe seul le port série. Fonctionne avec tous les backends, et avec les deux dialectes Hamlib (JTDX nomme le VFO, MSHV non).",
|
||||||
|
"CAT Xiegu natif (G90, X6100, X6200, X5105) : un nouveau backend dialogue en CI-V directement avec la radio — fréquence, mode, split et PTT. À choisir dans Réglages → CAT sous « Xiegu (CI-V natif) ». Pas encore testé sur une radio, retours bienvenus.",
|
||||||
|
"La console Yaesu suit le mode : en CW, le gain micro et le VOX disparaissent au profit d'une carte CW avec la vitesse du manipulateur, le break-in et ZIN (zéro-in).",
|
||||||
|
"CW par le keyer Yaesu : un cinquième moteur CW envoie vos macros avec le keyer de la radio (CAT « KY ») — sans WinKeyer ni second câble. À choisir dans Réglages → Keyer CW sous « Yaesu (keyer de la radio) ». Pour la famille FTDX101 / FT-991A / FT-710 : un FTDX10 ne l'accepte pas (DAKY compris), et OpsLog le dit en renvoyant vers le keyer série DTR sur son second port COM, qui fonctionne.",
|
||||||
|
"Antenna Genius : le port A ne bascule plus sur l'antenne du port B quelques secondes après un changement. Un seul port peut porter l'antenne d'émission, le switch renvoie donc la même sur les deux — l'affichage suit désormais l'antenne de réception propre à chaque port.",
|
||||||
|
"La vitesse CW est désormais un réglage unique où que vous la changiez : le curseur de la console Yaesu et le panneau CW pilotent le manipulateur qui émet réellement, et le keyer interne de la radio suit pour que sa façade affiche la même valeur.",
|
||||||
|
"Les mesures de la console Yaesu sont justes : la puissance lit l'instrument qui suit la HF (en watts, sur une courbe relevée face à l'affichage de la radio, et non le réglage de puissance) et le ROS affiche le rapport lui-même. Les deux lisaient le mauvais index — la barre de ROS restait vers 80 sur une antenne parfaite. Les barres retiennent aussi leur crête un instant avant de redescendre : elles restent lisibles pendant les silences entre les mots d’un CQ, en CW comme en phonie.",
|
||||||
|
"Voice keyer : un réglage de niveau pour les messages envoyés à la radio (Réglages → Audio). Ils partaient exactement tels qu'enregistrés : un micro capté faiblement attaquait donc la radio faiblement, sans rien dans OpsLog pour y remédier. Le bouton Lire fait entendre ce même niveau, et l'aide indique le réglage radio à vérifier quand elle n'émet presque rien (sur un FTDX10, SSB MOD SOURCE = REAR).",
|
||||||
|
"La liste PTT du voice keyer nomme aussi les backends Yaesu et Xiegu — avec un backend natif sélectionné, aucun nom de CAT n'apparaissait, ce qui se lisait « il n'y a pas de PTT CAT pour ma radio ».",
|
||||||
|
"WinKeyer 2 : la manipulation est réparée. Le rapport point/trait était envoyé sur la commande qui règle la COMPENSATION DE MANIPULATION : chaque élément recevait donc 50 ms de fermeture supplémentaire — à 25 mots/minute un point dure 48 ms — et le keyer envoyait un élément puis se bloquait. La compensation est aussi remise à zéro, car un keyer déjà réglé ainsi la conserve.",
|
||||||
|
"L'import ADIF accepte un fichier sans en-tête. Des enregistrements collés depuis un autre log — quelques lignes reprises de wsjtx_log.adi dans un nouveau .adi — donnaient « 0 importé, 0 ignoré, 0 au total », ce qui se lit comme un fichier vide plutôt que comme une balise manquante."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+376
-64
@@ -17,7 +17,7 @@ import {
|
|||||||
SMTPConfigured, SendLogToDeveloper,
|
SMTPConfigured, SendLogToDeveloper,
|
||||||
WorkedBefore,
|
WorkedBefore,
|
||||||
SetCompactMode,
|
SetCompactMode,
|
||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
|
||||||
GetSecretStatus, UnlockSecrets,
|
GetSecretStatus, UnlockSecrets,
|
||||||
RefreshCtyDat, DownloadAllReferenceLists,
|
RefreshCtyDat, DownloadAllReferenceLists,
|
||||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||||
|
QSOAudioManualReady, QSOAudioManualStart, QSOAudioStop, QSOAudioResume, QSOAudioPlayOnAir,
|
||||||
GetAwardDefs,
|
GetAwardDefs,
|
||||||
GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
|
GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
|
||||||
ReportLiveActivity, LiveLastQSOAgeSec,
|
ReportLiveActivity, LiveLastQSOAgeSec,
|
||||||
@@ -99,6 +100,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
|||||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||||
import { RotorCompass } from '@/components/RotorCompass';
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { formatDateTimeUTC } from '@/lib/dateFormat';
|
||||||
import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs';
|
import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs';
|
||||||
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
||||||
|
|
||||||
@@ -135,7 +137,15 @@ const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALV
|
|||||||
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
|
// 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,
|
// 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).
|
// 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 = {
|
const emptyDetails: DetailsState = {
|
||||||
state: '', cnty: '', address: '',
|
state: '', cnty: '', address: '',
|
||||||
@@ -168,11 +178,7 @@ function parseAwardRefs(s: any): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fmtDateUTC(s: any): string {
|
function fmtDateUTC(s: any): string {
|
||||||
if (!s) return '';
|
return formatDateTimeUTC(s);
|
||||||
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())}`;
|
|
||||||
}
|
}
|
||||||
function fmtFreq(hz?: number): string {
|
function fmtFreq(hz?: number): string {
|
||||||
if (!hz) return '';
|
if (!hz) return '';
|
||||||
@@ -387,11 +393,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() {
|
export default function App() {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
// === Lists from settings (fallback for first paint) ===
|
// === Lists from settings (fallback for first paint) ===
|
||||||
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
|
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
|
||||||
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
|
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
|
||||||
|
const modesRef = useRef(modes);
|
||||||
|
useEffect(() => { modesRef.current = modes; }, [modes]);
|
||||||
const [modePresets, setModePresets] = useState<ModePreset[]>([]);
|
const [modePresets, setModePresets] = useState<ModePreset[]>([]);
|
||||||
|
|
||||||
// === Entry ===
|
// === Entry ===
|
||||||
@@ -447,27 +482,6 @@ export default function App() {
|
|||||||
return next;
|
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 [band, setBand] = useState('20m');
|
||||||
const [mode, setMode] = useState('SSB');
|
const [mode, setMode] = useState('SSB');
|
||||||
const [freqMhz, setFreqMhz] = useState('');
|
const [freqMhz, setFreqMhz] = useState('');
|
||||||
@@ -766,16 +780,59 @@ export default function App() {
|
|||||||
// Elapsed recording time (seconds) shown next to the red dot, ticking once a
|
// Elapsed recording time (seconds) shown next to the red dot, ticking once a
|
||||||
// second while a recording is in progress.
|
// second while a recording is in progress.
|
||||||
const [recSeconds, setRecSeconds] = useState(0);
|
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
|
// 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).
|
// already true (jumping spot→spot keeps recording=true but starts a fresh take).
|
||||||
const [recTick, setRecTick] = useState(0);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!recording) { setRecSeconds(0); return; }
|
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();
|
const start = Date.now();
|
||||||
setRecSeconds(0);
|
const id = window.setInterval(() => setRecSeconds(from + Math.floor((Date.now() - start) / 1000)), 1000);
|
||||||
const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000);
|
|
||||||
return () => window.clearInterval(id);
|
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).
|
// The callsign the in-progress recording belongs to (uppercased; '' = none).
|
||||||
// Lets us restart from zero when the operator edits the call to a different
|
// Lets us restart from zero when the operator edits the call to a different
|
||||||
// station mid-recording, instead of continuing the old take.
|
// station mid-recording, instead of continuing the old take.
|
||||||
@@ -1198,7 +1255,7 @@ export default function App() {
|
|||||||
useEffect(() => { dvkAutoCqSecsRef.current = dvkAutoCqSecs; localStorage.setItem('opslog.dvkAutoCqSecs', String(dvkAutoCqSecs)); }, [dvkAutoCqSecs]);
|
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
|
// 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.
|
// 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);
|
const modeRef = useRef(mode);
|
||||||
useEffect(() => { modeRef.current = mode; }, [mode]);
|
useEffect(() => { modeRef.current = mode; }, [mode]);
|
||||||
function stopDvkAutoCq() { dvkAutoCqSlotRef.current = -1; dvkAutoCqGenRef.current++; }
|
function stopDvkAutoCq() { dvkAutoCqSlotRef.current = -1; dvkAutoCqGenRef.current++; }
|
||||||
@@ -1280,7 +1337,12 @@ export default function App() {
|
|||||||
// Status filter chips. Empty set = show every status (including
|
// Status filter chips. Empty set = show every status (including
|
||||||
// already-worked). Otherwise only matching spots pass.
|
// already-worked). Otherwise only matching spots pass.
|
||||||
type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
|
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';
|
||||||
|
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
|
||||||
// Mode filter chips. Empty set = show every mode. Categories map the
|
// Mode filter chips. Empty set = show every mode. Categories map the
|
||||||
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
||||||
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
|
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
|
||||||
@@ -1518,6 +1580,13 @@ export default function App() {
|
|||||||
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
|
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
|
||||||
const [importApplyCty, setImportApplyCty] = useState(true);
|
const [importApplyCty, setImportApplyCty] = useState(true);
|
||||||
const [importApplyStation, setImportApplyStation] = useState(false);
|
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
|
||||||
|
// US state and leaves the IOTA award empty.
|
||||||
|
const [importFieldMap, setImportFieldMap] = useState<{ from: string; to: string }[]>([]);
|
||||||
// QRZ profile photo lightbox (full-size, in-app — not the browser).
|
// QRZ profile photo lightbox (full-size, in-app — not the browser).
|
||||||
const [photoModal, setPhotoModal] = useState<string | null>(null);
|
const [photoModal, setPhotoModal] = useState<string | null>(null);
|
||||||
// Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the
|
// Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the
|
||||||
@@ -2037,9 +2106,29 @@ export default function App() {
|
|||||||
// a 599 left from a CW QSO would stick when jumping to an SSB spot.
|
// a 599 left from a CW QSO would stick when jumping to an SSB spot.
|
||||||
function applyModeFromSpot(m: string) {
|
function applyModeFromSpot(m: string) {
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
setMode(m);
|
const logged = catModeToLoggedMode(m);
|
||||||
|
setMode(logged);
|
||||||
rstUserEditedRef.current = false;
|
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) {
|
function applyModePreset(m: string) {
|
||||||
if (rstUserEditedRef.current) return;
|
if (rstUserEditedRef.current) return;
|
||||||
@@ -2206,8 +2295,9 @@ export default function App() {
|
|||||||
else if (s.mode) nextMode = s.mode;
|
else if (s.mode) nextMode = s.mode;
|
||||||
}
|
}
|
||||||
if (nextMode) {
|
if (nextMode) {
|
||||||
setMode(nextMode);
|
const logged = catModeToLoggedMode(nextMode);
|
||||||
applyModePreset(nextMode); // flip 599↔59 on mode change (respects edits)
|
setMode(logged);
|
||||||
|
applyModePreset(logged); // flip 599↔59 on mode change (respects edits)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2233,6 +2323,25 @@ export default function App() {
|
|||||||
FlexApplyBandAntenna(b).catch(() => {});
|
FlexApplyBandAntenna(b).catch(() => {});
|
||||||
}, [band, catState.backend, locks.band]);
|
}, [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,
|
// Cluster live wiring: hydrate per-server status + saved server list,
|
||||||
// then subscribe to push events.
|
// then subscribe to push events.
|
||||||
async function reloadClusterMeta() {
|
async function reloadClusterMeta() {
|
||||||
@@ -2397,6 +2506,14 @@ export default function App() {
|
|||||||
const call = String(p?.call ?? '');
|
const call = String(p?.call ?? '');
|
||||||
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
|
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
|
||||||
});
|
});
|
||||||
|
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) => {
|
const unsubProg = EventsOn('import:progress', (p: any) => {
|
||||||
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
|
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
|
||||||
});
|
});
|
||||||
@@ -2423,7 +2540,7 @@ export default function App() {
|
|||||||
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
||||||
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
||||||
});
|
});
|
||||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); unsubAdifMon?.(); };
|
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -2602,11 +2719,21 @@ export default function App() {
|
|||||||
if (isRig) {
|
if (isRig) {
|
||||||
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
|
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
|
||||||
} else {
|
} else {
|
||||||
// WinKeyer: watch the busy echo, capped by the estimate (+slack) since
|
// WinKeyer / serial keyer: the message takes AT LEAST its own length to
|
||||||
// over a remote/serial-over-IP link that echo can lag tens of seconds.
|
// send, so the estimate is a floor and the busy signal only extends it.
|
||||||
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500;
|
//
|
||||||
for (let i = 0; i < 20 && !wkBusyRef.current && !aborted(); i++) await sleep(50); // ≤1s to start
|
// Waiting on busy alone made the auto-call gap start early: busy travels
|
||||||
const deadline = Date.now() + capMs;
|
// from the keyer to this window as an event, and if it had not arrived
|
||||||
|
// and cleared within the one-second start window the wait simply ended.
|
||||||
|
// A 6 s gap then ran from the START of the CQ — about 2 s of silence
|
||||||
|
// after a 4 s call, which is what an operator hears as "clearly not 6".
|
||||||
|
const estMs = Math.round(estimateCwMs(resolved, wkWpm));
|
||||||
|
const started = Date.now();
|
||||||
|
const capMs = Math.round(estMs * 1.4) + 2500;
|
||||||
|
const deadline = started + capMs;
|
||||||
|
// The message's own duration, first — this is the part that must not be skipped.
|
||||||
|
while (Date.now() - started < estMs && !aborted()) await sleep(50);
|
||||||
|
// Then let a still-sending keyer finish (slow link, long buffer), capped.
|
||||||
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2920,9 +3047,9 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
async function bulkUpdateFromQRZ(ids: number[]) {
|
async function bulkUpdateFromQRZ(ids: number[]) {
|
||||||
if (ids.length === 0) return;
|
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'); }
|
try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); }
|
||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
finally { setBulkProgress(null); }
|
||||||
}
|
}
|
||||||
async function bulkUpdateFromClublog(ids: number[]) {
|
async function bulkUpdateFromClublog(ids: number[]) {
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
@@ -3355,7 +3482,13 @@ export default function App() {
|
|||||||
setImportErrorsOpen(false);
|
setImportErrorsOpen(false);
|
||||||
setImportDupsOpen(false);
|
setImportDupsOpen(false);
|
||||||
try {
|
try {
|
||||||
const res = await ImportADIF(path, importDupMode, importApplyCty, importApplyStation);
|
const fm: Record<string, string> = {};
|
||||||
|
for (const r of importFieldMap) {
|
||||||
|
const from = r.from.trim().toUpperCase();
|
||||||
|
const to = r.to.trim().toUpperCase();
|
||||||
|
if (from && to && from !== to) fm[from] = to;
|
||||||
|
}
|
||||||
|
const res = await ImportADIF(path, importDupMode, importApplyCty, importApplyStation, fm);
|
||||||
setImportResult(res);
|
setImportResult(res);
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -3583,8 +3716,15 @@ export default function App() {
|
|||||||
// single-row strip or the full Log4OM-style columnar layout below. Keeping
|
// single-row strip or the full Log4OM-style columnar layout below. Keeping
|
||||||
// them as shared consts avoids duplicating the (large) per-field JSX +
|
// them as shared consts avoids duplicating the (large) per-field JSX +
|
||||||
// handlers across the two layouts.
|
// 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 = (
|
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 }}>
|
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
|
||||||
<span className="text-primary font-semibold">{t('field.callsign')}</span>
|
<span className="text-primary font-semibold">{t('field.callsign')}</span>
|
||||||
{lookupBusy && (
|
{lookupBusy && (
|
||||||
@@ -3626,17 +3766,82 @@ export default function App() {
|
|||||||
DUPE
|
DUPE
|
||||||
</span>
|
</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={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.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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={resetRecordingClock}
|
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"
|
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"
|
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" />
|
<span className="size-2 rounded-full bg-destructive animate-pulse" />
|
||||||
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
|
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
|
||||||
</button>
|
</button>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<Input
|
<Input
|
||||||
ref={callsignRef}
|
ref={callsignRef}
|
||||||
@@ -3645,7 +3850,10 @@ export default function App() {
|
|||||||
// exactly the same. A primary-tinted border plus an inset left bar make
|
// 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
|
// 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.
|
// 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',
|
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)]',
|
'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')}
|
contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground shadow-none')}
|
||||||
value={callsign}
|
value={callsign}
|
||||||
@@ -3702,7 +3910,7 @@ export default function App() {
|
|||||||
) : null;
|
) : null;
|
||||||
const startBlock = (
|
const startBlock = (
|
||||||
<div className="flex flex-col w-28">
|
<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
|
<Input
|
||||||
readOnly={!locks.start}
|
readOnly={!locks.start}
|
||||||
tabIndex={locks.start ? 0 : -1}
|
tabIndex={locks.start ? 0 : -1}
|
||||||
@@ -3721,7 +3929,7 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const endBlock = (
|
const endBlock = (
|
||||||
<div className="flex flex-col w-28">
|
<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
|
<Input
|
||||||
readOnly={!locks.end}
|
readOnly={!locks.end}
|
||||||
tabIndex={locks.end ? 0 : -1}
|
tabIndex={locks.end ? 0 : -1}
|
||||||
@@ -3911,7 +4119,7 @@ export default function App() {
|
|||||||
// used in the full layout to save vertical height.
|
// used in the full layout to save vertical height.
|
||||||
const bandRow = (
|
const bandRow = (
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<div className="flex-1 min-w-0">
|
||||||
<Select value={band} onValueChange={onBandUserChange}>
|
<Select value={band} onValueChange={onBandUserChange}>
|
||||||
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
||||||
@@ -3922,7 +4130,7 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const modeRow = (
|
const modeRow = (
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<div className="flex-1 min-w-0">
|
||||||
<Select value={mode} onValueChange={onModeUserChange}>
|
<Select value={mode} onValueChange={onModeUserChange}>
|
||||||
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
||||||
@@ -3978,7 +4186,7 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
const freqBlock = (
|
const freqBlock = (
|
||||||
<div className="flex flex-col w-32">
|
<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
|
<Input
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
@@ -4088,7 +4296,10 @@ export default function App() {
|
|||||||
// entity status is still new-band/new-slot (the grid flags it WKD CALL),
|
// 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
|
// matching the "Hide worked" toggle. Additive: it still matches its own
|
||||||
// entity status too, so it stays visible under NEW BAND / NEW SLOT.
|
// 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'));
|
||||||
if (!matches) return false;
|
if (!matches) return false;
|
||||||
}
|
}
|
||||||
if (clusterHideWorked) {
|
if (clusterHideWorked) {
|
||||||
@@ -4200,10 +4411,14 @@ export default function App() {
|
|||||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<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' as SpotFilterKey, 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-band' as SpotFilterKey, 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-mode' as SpotFilterKey, 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-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-info-muted text-info-muted-foreground border-info-border' },
|
||||||
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
|
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
|
||||||
]).map((s) => {
|
]).map((s) => {
|
||||||
const on = clusterStatusFilter.has(s.k);
|
const on = clusterStatusFilter.has(s.k);
|
||||||
@@ -5098,6 +5313,11 @@ export default function App() {
|
|||||||
prefix={prefix}
|
prefix={prefix}
|
||||||
operatorGrid={station.my_grid}
|
operatorGrid={station.my_grid}
|
||||||
remoteGrid={grid}
|
remoteGrid={grid}
|
||||||
|
qth={qth}
|
||||||
|
name={name}
|
||||||
|
country={country}
|
||||||
|
comment={comment}
|
||||||
|
note={note}
|
||||||
details={details}
|
details={details}
|
||||||
onChange={updateDetails}
|
onChange={updateDetails}
|
||||||
wb={wb}
|
wb={wb}
|
||||||
@@ -5560,16 +5780,31 @@ export default function App() {
|
|||||||
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
|
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
|
||||||
>clear</button>
|
>clear</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{/* Three different numbers, so each one is LABELLED. "Showing
|
||||||
|
10000 of 23683 matches · 28648 total" was read as a filter
|
||||||
|
matching more than the log holds: the middle number belonged
|
||||||
|
to "matches" but sat where "of N" normally means the total.
|
||||||
|
Thousands separators for the same reason — 23683 and 28648
|
||||||
|
are hard to compare at a glance. */}
|
||||||
{gridFilteredCount != null ? (
|
{gridFilteredCount != null ? (
|
||||||
<span>
|
<span>
|
||||||
Showing <span className="font-semibold text-foreground">{gridFilteredCount}</span> of{' '}
|
Showing <span className="font-semibold text-foreground">{gridFilteredCount.toLocaleString()}</span> of{' '}
|
||||||
<span className="font-semibold text-foreground">{qsos.length}</span> (column filter)
|
<span className="font-semibold text-foreground">{qsos.length.toLocaleString()}</span> (column filter)
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
|
{(activeFilter.conditions?.length || filterCallsign) ? (
|
||||||
<span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
|
<>
|
||||||
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
|
Showing <span className="font-semibold text-foreground">{qsos.length.toLocaleString()}</span>
|
||||||
|
{' · '}<span className="font-semibold text-foreground">{(matchCount ?? 0).toLocaleString()}</span> match the filter
|
||||||
|
{' · '}<span className="font-semibold text-foreground">{total.toLocaleString()}</span> in the log
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Showing <span className="font-semibold text-foreground">{qsos.length.toLocaleString()}</span> of{' '}
|
||||||
|
<span className="font-semibold text-foreground">{total.toLocaleString()}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -6122,7 +6357,14 @@ export default function App() {
|
|||||||
<SettingsModal
|
<SettingsModal
|
||||||
initialSection={settingsSection}
|
initialSection={settingsSection}
|
||||||
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
|
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); }}
|
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
|
||||||
flexAvailable={catState.backend === 'flex'}
|
flexAvailable={catState.backend === 'flex'}
|
||||||
icomAvailable={catState.backend === 'icom'}
|
icomAvailable={catState.backend === 'icom'}
|
||||||
@@ -6298,6 +6540,47 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
{/* Field remapping. Hidden behind a link until asked for: it is the
|
||||||
|
rare case, and an import dialog that opens onto empty mapping
|
||||||
|
rows suggests something is required when nothing is. */}
|
||||||
|
<div className="pt-1 border-t mt-1">
|
||||||
|
{importFieldMap.length === 0 ? (
|
||||||
|
<button type="button"
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
onClick={() => setImportFieldMap([{ from: 'STATE', to: 'IOTA' }])}>
|
||||||
|
{t('imp.mapAdd')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-sm">{t('imp.mapTitle')}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t('imp.mapDesc')}</div>
|
||||||
|
{importFieldMap.map((row, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1.5">
|
||||||
|
<Input
|
||||||
|
value={row.from}
|
||||||
|
placeholder="STATE"
|
||||||
|
className="h-7 text-xs font-mono uppercase"
|
||||||
|
onChange={(e) => setImportFieldMap((m) => m.map((r, j) => j === i ? { ...r, from: e.target.value } : r))}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs">→</span>
|
||||||
|
<Input
|
||||||
|
value={row.to}
|
||||||
|
placeholder="IOTA"
|
||||||
|
className="h-7 text-xs font-mono uppercase"
|
||||||
|
onChange={(e) => setImportFieldMap((m) => m.map((r, j) => j === i ? { ...r, to: e.target.value } : r))}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => setImportFieldMap((m) => m.filter((_, j) => j !== i))}>✕</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button"
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
onClick={() => setImportFieldMap((m) => [...m, { from: '', to: '' }])}>
|
||||||
|
{t('imp.mapMore')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="px-2 bg-transparent border-t-0">
|
<DialogFooter className="px-2 bg-transparent border-t-0">
|
||||||
<Button variant="outline" onClick={() => setPendingImportPath(null)}>{t('imp.cancel')}</Button>
|
<Button variant="outline" onClick={() => setPendingImportPath(null)}>{t('imp.cancel')}</Button>
|
||||||
@@ -6307,6 +6590,35 @@ export default function App() {
|
|||||||
</Dialog>
|
</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 && (
|
{importing && (
|
||||||
<Dialog open>
|
<Dialog open>
|
||||||
<DialogContent className="max-w-sm px-6" hideClose>
|
<DialogContent className="max-w-sm px-6" hideClose>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export type AwardDef = {
|
|||||||
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
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;
|
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
|
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;
|
leading_str?: string; trailing_str?: string; dynamic?: boolean;
|
||||||
or_rules?: AwardOrRule[];
|
or_rules?: AwardOrRule[];
|
||||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
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 = {
|
type Explanation = {
|
||||||
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
|
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 };
|
type TestRow = { qso: any; explanation: Explanation };
|
||||||
const [testCall, setTestCall] = useState('');
|
const [testCall, setTestCall] = useState('');
|
||||||
@@ -554,6 +554,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</Field2>
|
</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]"><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>
|
<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">
|
<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>
|
<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>
|
</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 && (
|
{(ex.manual ?? []).length > 0 && (
|
||||||
<div className="px-3 py-2 text-xs">
|
<div className="px-3 py-2 text-xs">
|
||||||
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
|
<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 { cn } from '@/lib/utils';
|
||||||
import { AwardEditor } from '@/components/AwardEditor';
|
import { AwardEditor } from '@/components/AwardEditor';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
type BandCount = { band: string; worked: number; confirmed: number };
|
type BandCount = { band: string; worked: number; confirmed: number };
|
||||||
type AwardRef = {
|
type AwardRef = {
|
||||||
ref: string; name?: string; group?: string; subgrp?: string;
|
ref: string; name?: string; group?: string; subgrp?: string;
|
||||||
worked: boolean; confirmed: boolean; validated: boolean;
|
worked: boolean; confirmed: boolean; validated: boolean;
|
||||||
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
|
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
|
||||||
|
// Mode CLASSES — "CW" | "PHONE" | "DIGI" — not ADIF modes.
|
||||||
|
modes?: string[]; confirmed_modes?: string[];
|
||||||
};
|
};
|
||||||
type AwardResult = {
|
type AwardResult = {
|
||||||
code: string; name: string; dimension: string;
|
code: string; name: string; dimension: string;
|
||||||
@@ -74,6 +77,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
||||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
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 [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
|
||||||
const [showMissing, setShowMissing] = useState(false);
|
const [showMissing, setShowMissing] = useState(false);
|
||||||
const [stats, setStats] = useState<AwardStats | null>(null);
|
const [stats, setStats] = useState<AwardStats | null>(null);
|
||||||
@@ -191,17 +198,62 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
return idx.length ? idx : all;
|
return idx.length ? idx : all;
|
||||||
}, [stats, awardBands]);
|
}, [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(() => {
|
const filteredRefs = useMemo(() => {
|
||||||
if (!current) return [];
|
if (!current) return [];
|
||||||
const q = refSearch.trim().toUpperCase();
|
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 === 'worked' && !r.worked) return false;
|
||||||
if (refFilter === 'notworked' && r.worked) return false;
|
if (refFilter === 'notworked' && r.worked) return false;
|
||||||
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) 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;
|
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
|
||||||
return true;
|
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 (
|
return (
|
||||||
<div className="flex h-full min-h-0">
|
<div className="flex h-full min-h-0">
|
||||||
@@ -325,6 +377,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMissing(true)}
|
onClick={() => setShowMissing(true)}
|
||||||
@@ -386,8 +446,18 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||||
<thead className="sticky top-0 z-10">
|
<thead className="sticky top-0 z-10">
|
||||||
<tr className="bg-card">
|
<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="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">
|
||||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</th>
|
<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) => (
|
{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>
|
<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 +494,18 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 bg-card">
|
<thead className="sticky top-0 bg-card">
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
<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 w-24">
|
||||||
<th className="py-1.5 pr-2 font-medium">{t('awp.name')}</th>
|
<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-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 pr-2 font-medium w-24">{t('awp.status')}</th>
|
||||||
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
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 { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -53,6 +53,14 @@ interface Props {
|
|||||||
prefix: string;
|
prefix: string;
|
||||||
operatorGrid: string; // station.my_grid — origin for bearing/distance
|
operatorGrid: string; // station.my_grid — origin for bearing/distance
|
||||||
remoteGrid: string; // entry-strip Grid value — destination
|
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;
|
details: DetailsState;
|
||||||
onChange: (patch: Partial<DetailsState>) => void;
|
onChange: (patch: Partial<DetailsState>) => void;
|
||||||
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
|
// 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;
|
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.
|
// 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 (
|
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>
|
<Label className="mb-1">{label}</Label>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</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 { t } = useI18n();
|
||||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||||
|
|
||||||
// Live award detection: run the SAME engine used at log time over the current
|
// 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
|
// contact — callsign, the looked-up address/state/zones AND the entry-strip
|
||||||
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
|
// text (QTH, name, country, comment, note, grid). Sending only the address was
|
||||||
// surfaced and auto-added the moment you enter a call or click a spot, instead
|
// a silent hole: a DARC DOK matches on QTH, so "Aachen" produced nothing here
|
||||||
// of only appearing after logging. Pickable matches are merged into award_refs
|
// 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).
|
// (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(() => {
|
useEffect(() => {
|
||||||
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
|
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
|
||||||
const t = window.setTimeout(async () => {
|
const t = window.setTimeout(async () => {
|
||||||
@@ -142,6 +179,8 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
const q: any = {
|
const q: any = {
|
||||||
callsign, band, mode,
|
callsign, band, mode,
|
||||||
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
|
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,
|
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
|
||||||
qso_date: new Date().toISOString(),
|
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));
|
const have = new Set(cur.split(';').filter(Boolean));
|
||||||
let next = cur;
|
let next = cur;
|
||||||
for (const r of all) {
|
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()}`;
|
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
|
||||||
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
|
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);
|
}, 400);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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.
|
// Bearing/distance from operator's home grid to the remote station.
|
||||||
// Recomputed only when either grid actually changes.
|
// Recomputed only when either grid actually changes.
|
||||||
const path = useMemo(() => {
|
const path = useMemo(() => {
|
||||||
@@ -227,14 +268,26 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{open === 'info' && (
|
{open === 'info' && (
|
||||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
<div className="grid grid-cols-12 gap-2 px-3 py-2.5">
|
||||||
<Field label={t('detp.statePref')}>
|
{/* 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 })} />
|
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.county')}>
|
<Field label={t('detp.county')} span={4} className="relative">
|
||||||
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
|
<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>
|
||||||
<Field label={t('detp.prefix')}>
|
<Field label={t('detp.prefix')} span={2}>
|
||||||
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.cqZone')}>
|
<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
|
{/* DXCC # closes the top row (next to the zones); Continent and
|
||||||
Azimuth SP live in the main entry strip / bandeau. The long-path
|
Azimuth SP live in the main entry strip / bandeau. The long-path
|
||||||
bearing and distances move to the row below. */}
|
bearing and distances move to the row below. */}
|
||||||
<Field label={t('detp.dxcc')}>
|
<Field label={t('detp.dxcc')} span={2}>
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -257,7 +310,11 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.azimuthLp')}>
|
{/* 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
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -266,7 +323,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.distanceSp')}>
|
<Field label={t('detp.distanceSp')} className="w-24 shrink-0">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -275,7 +332,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.distanceLp')}>
|
<Field label={t('detp.distanceLp')} className="w-24 shrink-0">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -284,13 +341,14 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.address')} span={3}>
|
<Field label={t('detp.address')} className="flex-1 min-w-0">
|
||||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.qslMessage')} span={3}>
|
</div>
|
||||||
|
<Field label={t('detp.qslMessage')} span={7}>
|
||||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||||
</Field>
|
</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 })} />
|
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,10 +363,37 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
|
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
|
||||||
heightClass="flex-1 min-h-0"
|
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">
|
<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>{' '}
|
<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 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 className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { LogUIError } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// A render error used to empty the window and leave nothing behind: no line in
|
||||||
|
// opslog.log, no dialog, no way for the operator to say more than "white screen".
|
||||||
|
// Two separate reports — one after logging a 10 GHz QSO, one on starting a DVK
|
||||||
|
// auto-call — stayed undiagnosed for weeks for exactly that reason.
|
||||||
|
//
|
||||||
|
// So this does two things, in order of importance:
|
||||||
|
//
|
||||||
|
// 1. Writes the error and its stack to the app log through LogUIError, so the
|
||||||
|
// next report arrives WITH the cause instead of a description of a blank
|
||||||
|
// screen.
|
||||||
|
// 2. Shows it, with the text selectable and a button to copy it, because the
|
||||||
|
// person who can act on it is the one looking at the screen.
|
||||||
|
//
|
||||||
|
// It deliberately does NOT try to recover the tree. React cannot guarantee the
|
||||||
|
// state is sane after a render throw, and a half-working logger is worse than
|
||||||
|
// one that says plainly what happened and offers a reload.
|
||||||
|
|
||||||
|
type Props = { children: React.ReactNode };
|
||||||
|
type State = { error: Error | null; info: string };
|
||||||
|
|
||||||
|
export class ErrorBoundary extends React.Component<Props, State> {
|
||||||
|
state: State = { error: null, info: '' };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
|
const stack = (info?.componentStack || error.stack || '').trim();
|
||||||
|
this.setState({ info: stack });
|
||||||
|
// Never let the reporting path throw: it runs while the app is already broken.
|
||||||
|
try {
|
||||||
|
LogUIError('render crash', `${error.name}: ${error.message}`, stack);
|
||||||
|
} catch {
|
||||||
|
/* the backend may be gone too — the on-screen copy still works */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { error, info } = this.state;
|
||||||
|
if (!error) return this.props.children;
|
||||||
|
|
||||||
|
const report = `${error.name}: ${error.message}\n\n${info}`;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 24, background: '#0b1220', color: '#e5e7eb', fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||||
|
overflow: 'auto', zIndex: 9999,
|
||||||
|
}}>
|
||||||
|
<div style={{ maxWidth: 820, width: '100%' }}>
|
||||||
|
<h1 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 6px' }}>
|
||||||
|
OpsLog hit an error and stopped drawing
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 13, opacity: 0.8, margin: '0 0 14px' }}>
|
||||||
|
Your log is safe — this is the window, not the database. The details below
|
||||||
|
are already in the app log; send them with your report.
|
||||||
|
</p>
|
||||||
|
<pre style={{
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word', userSelect: 'text',
|
||||||
|
fontSize: 11.5, lineHeight: 1.5, background: '#111827', border: '1px solid #1f2937',
|
||||||
|
borderRadius: 8, padding: 12, maxHeight: '48vh', overflow: 'auto', margin: 0,
|
||||||
|
}}>{report}</pre>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { void navigator.clipboard?.writeText(report); }}
|
||||||
|
style={{
|
||||||
|
padding: '7px 14px', borderRadius: 6, border: '1px solid #374151',
|
||||||
|
background: '#1f2937', color: '#e5e7eb', fontSize: 13, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
Copy the details
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
style={{
|
||||||
|
padding: '7px 14px', borderRadius: 6, border: '1px solid #1d4ed8',
|
||||||
|
background: '#2563eb', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
Reload the window
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// installGlobalErrorLogging catches what a boundary cannot: an error thrown from
|
||||||
|
// a timer, an event handler or a rejected promise. Those do not unmount the tree,
|
||||||
|
// so they leave no trace at all — and an auto-call loop lives entirely in
|
||||||
|
// callbacks, which is precisely where a white screen was reported.
|
||||||
|
export function installGlobalErrorLogging() {
|
||||||
|
const report = (kind: string, message: string, stack?: string) => {
|
||||||
|
try {
|
||||||
|
LogUIError(kind, message, stack || '');
|
||||||
|
} catch {
|
||||||
|
/* nothing more we can do from here */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('error', (e: ErrorEvent) => {
|
||||||
|
const src = e.filename ? ` (${e.filename}:${e.lineno}:${e.colno})` : '';
|
||||||
|
report('uncaught error', (e.message || 'unknown error') + src, e.error?.stack);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
||||||
|
const r: any = e.reason;
|
||||||
|
report('unhandled rejection', String(r?.message ?? r ?? 'unknown'), r?.stack);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { nightPolygon } from '../lib/greyline';
|
||||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
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
|
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
||||||
// across restarts. Default on.
|
// across restarts. Default on.
|
||||||
const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0');
|
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);
|
const autoZoomRef = useRef(autoZoom);
|
||||||
useEffect(() => { autoZoomRef.current = autoZoom; }, [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);
|
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||||
}, [basemap]);
|
}, [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.
|
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wm = worldMap.current, wo = worldOverlay.current;
|
const wm = worldMap.current, wo = worldOverlay.current;
|
||||||
@@ -293,6 +374,21 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
>
|
>
|
||||||
Zoom DX
|
Zoom DX
|
||||||
</button>
|
</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 && (
|
{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 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
|
<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 { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
||||||
import { useI18n } from '@/lib/i18n';
|
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.)
|
// 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) {
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||||
const { t } = useI18n();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
const close = () => onClose();
|
const close = () => onClose();
|
||||||
@@ -50,16 +56,40 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
};
|
};
|
||||||
}, [menu, onClose]);
|
}, [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;
|
if (!menu) return null;
|
||||||
const n = menu.ids.length;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed z-[200] min-w-[240px] rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
ref={boxRef}
|
||||||
style={{ left: x, top: y }}
|
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()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
<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,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
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 { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
@@ -85,23 +87,13 @@ function fmtMhzDots(hz?: number): string {
|
|||||||
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
|
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 {
|
function fmtDateUTC(s: any): string {
|
||||||
if (!s) return '';
|
return formatDateTimeUTC(s);
|
||||||
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())}`;
|
|
||||||
}
|
}
|
||||||
function fmtDateOnly(s: any): string {
|
function fmtDateOnly(s: any): string {
|
||||||
if (!s) return '';
|
return formatDateOnly(s);
|
||||||
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())}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
// 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 },
|
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||||
|
|
||||||
// ── QSL ──
|
// ── 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_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 },
|
{ 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_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_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 },
|
{ 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 },
|
{ 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 ──
|
// ── 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_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 },
|
{ 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_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) },
|
{ 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 ──
|
// ── 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_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 },
|
{ 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_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) },
|
{ 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.
|
// 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.
|
// 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) ──
|
// ── Uploads (online logbooks) ──
|
||||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
// 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
|
// 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
|
// 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.
|
// 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.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.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_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 },
|
{ 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_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) },
|
{ 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
|
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
||||||
|
|
||||||
// Localized column catalog — rebuilt when the language changes.
|
// 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,
|
// 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.
|
// select just it; then open the bulk-action menu on the whole selection.
|
||||||
@@ -444,10 +440,18 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
if (widthUps.length) api.setColumnWidths(widthUps);
|
if (widthUps.length) api.setColumnWidths(widthUps);
|
||||||
}, [awardCols, awardShown]);
|
}, [awardCols, awardShown]);
|
||||||
|
|
||||||
|
// No column filters here, deliberately. This grid holds only the most recent
|
||||||
|
// page of QSOs, so a column filter searches THOSE rows and nothing else — and
|
||||||
|
// an empty result then reads as "the log does not contain it" rather than "it
|
||||||
|
// is not on this page". An operator hunting an imported CONTEST_ID across
|
||||||
|
// 28 000 QSOs found nothing and reasonably concluded the import had dropped it.
|
||||||
|
//
|
||||||
|
// The advanced filter queries the whole logbook and is the honest tool for it.
|
||||||
|
// Removing the filter also gives each header back the width of its menu icon.
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: !passOrder,
|
sortable: !passOrder,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
filter: true,
|
filter: false,
|
||||||
suppressMovable: false,
|
suppressMovable: false,
|
||||||
}), [passOrder]);
|
}), [passOrder]);
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||||
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||||
GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted,
|
GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted,
|
||||||
GetSecretStatus, SetPassphrase, RemovePassphrase,
|
GetSecretStatus, SetPassphrase, RemovePassphrase,
|
||||||
@@ -36,6 +36,9 @@ import {
|
|||||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
SetWinkeyerTrace,
|
SetWinkeyerTrace,
|
||||||
|
SetCIVTrace,
|
||||||
|
CIVTraceEnabled,
|
||||||
|
WinkeyerTraceEnabled,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
@@ -44,7 +47,7 @@ import {
|
|||||||
DownloadULSCounties, ULSStatus, BackfillUSCounties,
|
DownloadULSCounties, ULSStatus, BackfillUSCounties,
|
||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
GetUIPref, SetUIPref,
|
GetUIPref, SetUIPref,
|
||||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower,
|
||||||
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
@@ -66,6 +69,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
|
||||||
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||||
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
||||||
import { OperatingPanel } from '@/components/OperatingPanel';
|
import { OperatingPanel } from '@/components/OperatingPanel';
|
||||||
@@ -198,7 +202,35 @@ type SectionId =
|
|||||||
|
|
||||||
type TreeNode =
|
type TreeNode =
|
||||||
| { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
|
| { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
|
||||||
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean };
|
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean; vendor?: 'o3a' };
|
||||||
|
|
||||||
|
// A compact 4O3A wordmark for the sidebar, drawn here rather than shipped as an
|
||||||
|
// image: an inline SVG scales with the row, follows the theme (currentColor), and
|
||||||
|
// adds no binary asset. It marks which panels drive 4O3A hardware — the same
|
||||||
|
// reason the language picker draws its flags inline.
|
||||||
|
//
|
||||||
|
// This is a plain wordmark, NOT the manufacturer's logo artwork. If you would
|
||||||
|
// rather show the official mark, drop the SVG in and swap this component out.
|
||||||
|
function VendorMark({ vendor }: { vendor: 'o3a' }) {
|
||||||
|
if (vendor !== 'o3a') return null;
|
||||||
|
// The 4O3A mark: "4", the green waveform, "3A". Drawn inline rather than
|
||||||
|
// shipped as an image — it scales with the row, keeps the sidebar's own type
|
||||||
|
// for the digits, and adds no binary asset. The waveform is the part that
|
||||||
|
// carries the brand, so only it is SVG; the characters stay HTML text so they
|
||||||
|
// match the surrounding menu at any theme or zoom.
|
||||||
|
const lens = [3.2, 5.2, 7.4, 9.5, 7.4, 5.2, 3.2]; // half-heights, tallest in the middle
|
||||||
|
return (
|
||||||
|
<span className="ml-1.5 flex shrink-0 items-center gap-[1px] text-[10px] font-bold leading-none opacity-80" title="4O3A Signature">
|
||||||
|
<span>4</span>
|
||||||
|
<svg viewBox="0 0 26 22" className="h-[13px] w-[15px]" aria-hidden="true">
|
||||||
|
{lens.map((ry, i) => (
|
||||||
|
<ellipse key={i} cx={3 + i * 3.3} cy={11} rx={1.5} ry={ry} fill="#1f9d3a" />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<span>3A</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// buildTree returns the settings sidebar. The FlexRadio item only appears when
|
// buildTree returns the settings sidebar. The FlexRadio item only appears when
|
||||||
// the active CAT backend is a Flex (per-band antenna config is Flex-specific).
|
// the active CAT backend is a Flex (per-band antenna config is Flex-specific).
|
||||||
@@ -208,8 +240,8 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
{ kind: 'item', label: t('sec.rotator'), id: 'rotator' },
|
{ kind: 'item', label: t('sec.rotator'), id: 'rotator' },
|
||||||
{ kind: 'item', label: t('sec.winkeyer'), id: 'winkeyer' },
|
{ kind: 'item', label: t('sec.winkeyer'), id: 'winkeyer' },
|
||||||
{ kind: 'item', label: t('sec.antenna'), id: 'antenna' },
|
{ kind: 'item', label: t('sec.antenna'), id: 'antenna' },
|
||||||
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius' },
|
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius', vendor: 'o3a' },
|
||||||
{ kind: 'item', label: t('sec.tunergenius'), id: 'tunergenius' },
|
{ kind: 'item', label: t('sec.tunergenius'), id: 'tunergenius', vendor: 'o3a' },
|
||||||
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
|
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
|
||||||
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
|
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
|
||||||
{ kind: 'item', label: t('sec.relayauto'), id: 'relayauto' },
|
{ kind: 'item', label: t('sec.relayauto'), id: 'relayauto' },
|
||||||
@@ -325,6 +357,7 @@ function TreeNodeView({
|
|||||||
style={{ paddingLeft: 8 + depth * 14 }}
|
style={{ paddingLeft: 8 + depth * 14 }}
|
||||||
>
|
>
|
||||||
<span className="truncate">{node.label}</span>
|
<span className="truncate">{node.label}</span>
|
||||||
|
{node.vendor && !node.disabled && <VendorMark vendor={node.vendor} />}
|
||||||
{node.disabled && (
|
{node.disabled && (
|
||||||
<Construction className="ml-auto size-3 shrink-0 opacity-60" />
|
<Construction className="ml-auto size-3 shrink-0 opacity-60" />
|
||||||
)}
|
)}
|
||||||
@@ -920,81 +953,121 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlexBandAntennasPanel — pick the RX/TX antenna per band. Applied automatically
|
// FlexBandPanel — everything that follows the band, in ONE row per band:
|
||||||
// when the band changes (frequency change / spot click). Antennas come live from
|
// antennas and TX power.
|
||||||
// the connected FlexRadio; bands come from Lists → Bands.
|
//
|
||||||
function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
|
// 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 [rxList, setRxList] = useState<string[]>([]);
|
||||||
const [txList, setTxList] = 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('');
|
const [msg, setMsg] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
GetFlexState().then((s: any) => {
|
GetFlexState().then((s: any) => {
|
||||||
setRxList((s?.ant_list ?? []) as string[]);
|
setRxList((s?.ant_list ?? []) as string[]);
|
||||||
setTxList(((s?.tx_ant_list?.length ? s.tx_ant_list : s?.ant_list) ?? []) as string[]);
|
setTxList(((s?.tx_ant_list?.length ? s.tx_ant_list : s?.ant_list) ?? []) as string[]);
|
||||||
}).catch(() => {});
|
}).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();
|
const key = band.toUpperCase();
|
||||||
setMap((m) => {
|
setAnt((m) => {
|
||||||
const cur = m[key] ?? { rx: '', tx: '' };
|
const next = { ...m, [key]: { ...(m[key] ?? { rx: '', tx: '' }), [side]: v } };
|
||||||
const next = { ...m, [key]: { ...cur, [side]: v } };
|
SaveFlexBandAntennas(next as any).then(saved).catch((e: any) => setMsg(String(e?.message ?? e)));
|
||||||
SaveFlexBandAntennas(next as any)
|
|
||||||
.then(() => { setMsg('Saved'); window.setTimeout(() => setMsg(''), 1200); })
|
|
||||||
.catch((e: any) => setMsg(String(e?.message ?? e)));
|
|
||||||
return next;
|
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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-foreground">FlexRadio — per-band antennas</h3>
|
<h3 className="text-base font-semibold text-foreground">{t('flxb.title')}</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{t('flxb.hint')}</p>
|
||||||
Choose the RX and TX antenna for each band. They're applied automatically when the band
|
|
||||||
changes (frequency change or clicking a spot).
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{rxList.length === 0 && (
|
{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">
|
<div className="text-xs text-warning-muted-foreground bg-warning-muted border border-warning-border rounded-md px-3 py-2 max-w-2xl">
|
||||||
No antennas reported yet — make sure the FlexRadio is connected (CAT interface), then reopen this panel.
|
{t('flxb.noRadio')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{bands.length === 0 ? (
|
{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">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/40 text-muted-foreground text-xs">
|
<thead className="bg-muted/40 text-muted-foreground text-xs">
|
||||||
<tr>
|
<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">{t('flxpw.band')}</th>
|
||||||
<th className="text-left px-3 py-1.5 font-semibold">RX antenna</th>
|
<th className="text-left px-2 py-1.5 font-semibold">{t('flxb.rxAnt')}</th>
|
||||||
<th className="text-left px-3 py-1.5 font-semibold">TX antenna</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{bands.map((b) => {
|
{bands.map((b) => (
|
||||||
const e = map[b.toUpperCase()] ?? { rx: '', tx: '' };
|
|
||||||
return (
|
|
||||||
<tr key={b} className="border-t border-border/50">
|
<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 font-mono font-semibold">{b}</td>
|
||||||
<td className="px-3 py-1.5">
|
{antCell(b, 'rx', rxList)}
|
||||||
<select value={e.rx ?? ''} onChange={(ev) => set(b, 'rx', ev.target.value)}
|
{antCell(b, 'tx', txList)}
|
||||||
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
|
{pwrCell(b, 'phone')}
|
||||||
<option value="">— none —</option>
|
{pwrCell(b, 'cw')}
|
||||||
{rxList.map((a) => <option key={a} value={a}>{a}</option>)}
|
{pwrCell(b, 'digi')}
|
||||||
</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>
|
</tr>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -1063,7 +1136,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [modeDraft, setModeDraft] = useState('');
|
const [modeDraft, setModeDraft] = useState('');
|
||||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
||||||
yaesu_port: '', yaesu_baud: 38400, xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70,
|
yaesu_port: '', yaesu_baud: 38400, 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,
|
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||||
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
||||||
@@ -1106,7 +1179,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
});
|
});
|
||||||
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
||||||
// Session-only: the byte trace is a diagnostic, never a saved preference.
|
// 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 [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 }));
|
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
||||||
|
|
||||||
// ── Audio (DVK + QSO recorder) ──
|
// ── Audio (DVK + QSO recorder) ──
|
||||||
@@ -1114,13 +1198,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
||||||
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
||||||
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
|
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 };
|
type AudioDev = { id: string; name: string; default: boolean };
|
||||||
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
||||||
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
||||||
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
|
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 [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
|
||||||
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
|
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
|
||||||
@@ -1245,7 +1329,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
upload_flags: string[]; write_log: boolean;
|
upload_flags: string[]; write_log: boolean;
|
||||||
auto_upload: boolean; upload_mode: string;
|
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 => ({
|
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||||
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||||
@@ -1253,7 +1337,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
auto_upload: false, upload_mode: 'immediate',
|
auto_upload: false, upload_mode: 'immediate',
|
||||||
});
|
});
|
||||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
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 [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [qrzTesting, setQrzTesting] = useState(false);
|
const [qrzTesting, setQrzTesting] = useState(false);
|
||||||
@@ -2350,6 +2434,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<SelectItem value="omnirig">{t('cat.optOmnirig')}</SelectItem>
|
<SelectItem value="omnirig">{t('cat.optOmnirig')}</SelectItem>
|
||||||
<SelectItem value="flex">{t('cat.optFlex')}</SelectItem>
|
<SelectItem value="flex">{t('cat.optFlex')}</SelectItem>
|
||||||
<SelectItem value="yaesu">{t('cat.optYaesu')}</SelectItem>
|
<SelectItem value="yaesu">{t('cat.optYaesu')}</SelectItem>
|
||||||
|
<SelectItem value="kenwood">{t('cat.optKenwood')}</SelectItem>
|
||||||
<SelectItem value="xiegu">{t('cat.optXiegu')}</SelectItem>
|
<SelectItem value="xiegu">{t('cat.optXiegu')}</SelectItem>
|
||||||
<SelectItem value="icom">{t('cat.optIcom')}</SelectItem>
|
<SelectItem value="icom">{t('cat.optIcom')}</SelectItem>
|
||||||
<SelectItem value="icom-net">{t('cat.optIcomNet')}</SelectItem>
|
<SelectItem value="icom-net">{t('cat.optIcomNet')}</SelectItem>
|
||||||
@@ -2453,6 +2538,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}} />
|
}} />
|
||||||
<span className="text-xs text-muted-foreground">{t('cat.xieguAddrHint')}</span>
|
<span className="text-xs text-muted-foreground">{t('cat.xieguAddrHint')}</span>
|
||||||
</div>
|
</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' && (
|
{catCfg.backend === 'yaesu' && (
|
||||||
@@ -2484,6 +2582,53 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{['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); }} />
|
||||||
|
{t('cat.civTrace')}
|
||||||
|
</label>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">{t('cat.civTraceHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{catCfg.backend === 'kenwood' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.kenwoodPort')}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={catCfg.kenwood_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_port: v }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder={t('cat.selectCom')} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>{t('cat.noPorts')}</SelectItem>}
|
||||||
|
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button type="button" variant="outline" size="sm"
|
||||||
|
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('cat.kenwoodPortHint')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.baud')}</Label>
|
||||||
|
<Select value={String(catCfg.kenwood_baud || 9600)} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_baud: parseInt(v) || 9600 }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('cat.kenwoodBaudHint')}</span>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{catCfg.backend === 'icom' && (
|
{catCfg.backend === 'icom' && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -3169,7 +3314,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||||||
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
||||||
<SelectItem value="arco">microHAM ARCO (LAN, GS-232A)</SelectItem>
|
<SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC…)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -3216,6 +3361,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
<ArrowDown className="size-3.5 rotate-90" />
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Select value={String((rotator as any).baud || 9600)}
|
||||||
|
onValueChange={(v) => setRotator((s) => ({ ...s, baud: Number(v) } as any))}>
|
||||||
|
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[4800, 9600, 19200, 38400, 57600].map((b) => (
|
||||||
|
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -4124,6 +4278,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
|
|
||||||
<ProfileScopeNote profile={activeProfileObj} />
|
<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 */}
|
{/* Tab strip */}
|
||||||
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
|
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
@@ -4959,13 +5128,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Label className="text-sm">{t('aud.fromLevel')}</Label>
|
<Label className="text-sm">{t('aud.fromLevel')}</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
|
<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>
|
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
|
||||||
</div>
|
</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>
|
<Label className="text-sm">{t('aud.micLevel')}</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
|
<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>
|
<span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -5108,6 +5283,27 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</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 />
|
<ThemeSelector />
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -5454,7 +5650,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
antgenius: AntGeniusPanelSettings,
|
antgenius: AntGeniusPanelSettings,
|
||||||
tunergenius: TunerGeniusPanelSettings,
|
tunergenius: TunerGeniusPanelSettings,
|
||||||
pgxl: PGXLPanelSettings,
|
pgxl: PGXLPanelSettings,
|
||||||
flex: () => <FlexBandAntennasPanel bands={lists.bands ?? []} />,
|
flex: () => <FlexBandPanel bands={lists.bands ?? []} />,
|
||||||
audio: AudioPanel,
|
audio: AudioPanel,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||||||
|
import { getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -63,7 +64,9 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
|
|||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
|
|
||||||
// Localized column catalog (shared with the Recent QSOs grid).
|
// 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>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ const TabsContent = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
ref={ref}
|
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}
|
{...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;
|
||||||
|
}
|
||||||
+26
-26
@@ -63,7 +63,7 @@ const en: Dict = {
|
|||||||
// Language chooser
|
// Language chooser
|
||||||
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
||||||
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': "What's new", 'whatsnew.close': 'Got it', 'whatsnew.none': 'No changelog available for this version yet.',
|
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': "What's new", 'whatsnew.close': 'Got it', 'whatsnew.none': 'No changelog available for this version yet.',
|
||||||
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
'settings.language': 'Language', 'gen.dateFormat': 'Date display', 'gen.dateStandard': 'Standard', 'gen.dateFR': 'French', 'gen.dateUS': 'US', 'settings.languageHint': 'Interface language.',
|
||||||
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
|
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
|
||||||
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
|
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
|
||||||
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
|
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
|
||||||
@@ -134,9 +134,9 @@ const en: Dict = {
|
|||||||
'imp.ctyTitle': 'Fix country & zones (cty.dat + ClubLog)',
|
'imp.ctyTitle': 'Fix country & zones (cty.dat + ClubLog)',
|
||||||
'imp.ctyDesc': "Recompute Country, DXCC & CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF → Reunion, TO2A 2012 → French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use Update duplicates to re-fix QSOs already in your log.",
|
'imp.ctyDesc': "Recompute Country, DXCC & CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF → Reunion, TO2A 2012 → French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use Update duplicates to re-fix QSOs already in your log.",
|
||||||
'imp.stationTitle': 'Fill my station fields from my profile',
|
'imp.stationTitle': 'Fill my station fields from my profile',
|
||||||
'imp.stationDesc': "Backfill empty MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus Operator and Owner callsign from your active profile. Existing values are kept. Only STATION_CALLSIGN is left untouched so a mixed-call log isn't re-routed. Enable when importing your own log.",
|
'imp.stationDesc': "Backfill empty MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus Operator and Owner callsign from your active profile. Existing values are kept. Only STATION_CALLSIGN is left untouched so a mixed-call log isn't re-routed. It ALSO stamps your default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com sent and received) on the ones the file leaves empty — a WSJT-X log carries almost none. Enable when importing your own log.",
|
||||||
'imp.cancel': 'Cancel', 'imp.import': 'Import',
|
'imp.cancel': 'Cancel', 'rec.manualStart': 'Record this contact. Automatic recording is off, so capture starts now \u2014 there is no pre-roll of what came before.', 'rec.stop': 'Stop the recording. The audio is kept and still saved with the QSO \u2014 stop it to play it back to the station you are working.', 'rec.resume': 'Carry on recording, appending to what is already captured.', 'rec.playOnAir': 'TRANSMIT the recording to the station you are working: keys the radio and sends it like a voice-keyer message.', 'rec.stopPlaying': 'Stop transmitting the recording', 'rec.manualFailed': 'Recording could not be started \u2014 check the audio devices in Settings.', 'imp.mapAdd': 'The file puts a field in the wrong place\u2026', 'imp.mapTitle': 'Move fields on import', 'imp.mapDesc': 'Contest software stores the exchange where its own module keeps it. The RSGB IOTA contest exports the island reference in STATE \u2014 imported as-is it becomes a US state and the IOTA award stays empty. The destination is only filled where the file left it blank.', 'imp.mapMore': 'Add another', 'imp.import': 'Import',
|
||||||
'imp.progressTitle': 'Importing ADIF…',
|
'imp.progressTitle': 'Importing ADIF…', 'bulk.progressTitle': 'Updating the selected QSOs\u2026',
|
||||||
'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…',
|
'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…',
|
||||||
'imp.complete': 'Import complete.',
|
'imp.complete': 'Import complete.',
|
||||||
'imp.imported': '{n} imported', 'imp.updated': '{n} updated', 'imp.duplicates': '{n} duplicates', 'imp.skipped': '{n} skipped', 'imp.total': '{n} total',
|
'imp.imported': '{n} imported', 'imp.updated': '{n} updated', 'imp.duplicates': '{n} duplicates', 'imp.skipped': '{n} skipped', 'imp.total': '{n} total',
|
||||||
@@ -273,11 +273,11 @@ const en: Dict = {
|
|||||||
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
|
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
|
||||||
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||||
'tg2.hint': 'OpsLog talks to the 4O3A Tuner Genius XL over TCP (port fixed at 9010), so only the device IP is needed. A docked widget then shows SWR and forward power and offers Tune, Bypass and Operate/Standby. Control it directly (not through the radio) so OpsLog uses just one of the box\'s connection slots.', 'tg2.enable': 'Enable Tuner Genius control', 'tg2.portHint': 'The TCP port is fixed at 9010 on the device.', 'tg2.password': 'Remote code', 'tg2.passwordPh': 'blank on LAN', 'tg2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
'tg2.hint': 'OpsLog talks to the 4O3A Tuner Genius XL over TCP (port fixed at 9010), so only the device IP is needed. A docked widget then shows SWR and forward power and offers Tune, Bypass and Operate/Standby. Control it directly (not through the radio) so OpsLog uses just one of the box\'s connection slots.', 'tg2.enable': 'Enable Tuner Genius control', 'tg2.portHint': 'The TCP port is fixed at 9010 on the device.', 'tg2.password': 'Remote code', 'tg2.passwordPh': 'blank on LAN', 'tg2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||||
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to a microHAM ARCO — no PstRotator needed. LAN: set Config → LAN → CONTROL PROTOCOL to 'Yaesu GS-232A' on the ARCO and enter the same TCP port here (up to 4 parallel connections). USB: set USB CONTROL PROTOCOL to 'Yaesu GS-232A' and pick the ARCO's COM port here (baud rate doesn't matter on USB).", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to any controller set to Yaesu GS-232A — no PstRotator needed. microHAM ARCO: set Config → LAN → CONTROL PROTOCOL (or USB CONTROL PROTOCOL) to 'Yaesu GS-232A'; on USB the baud rate does not matter. ERC (Easy Rotor Control, incl. ERC Mini): its emulation MUST be set to GS-232 — an ERC left on Hy-Gain DCU-1 speaks a different command set and will not answer — then pick its COM port and the same baud rate as in the ERC configuration.", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
||||||
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
||||||
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||||
// CAT panel body
|
// CAT panel body
|
||||||
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.optXiegu': 'Xiegu (native CI-V)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.optYaesu': 'Yaesu (native CAT)', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.xieguPTTLine': 'How the rig is keyed', 'cat.xieguPTTCiv': 'CI-V command', 'cat.xieguPTTHint': 'A G90 does not transmit on the CI-V command: interfaces like the DE-19 key it on RTS or DTR. Pick the line yours uses \u2014 it is also what lets WSJT-X transmit through the shared CAT link.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 power per band and mode', 'flxpw.hint': 'TX power applied when the band or mode changes. Leave a box empty to leave the power alone.', 'flxpw.band': 'Band', 'flxb.title': 'FlexRadio \u2014 per-band antennas and power', 'flxb.hint': 'Antennas are applied when the band changes; power when the band or the mode changes. Leave a power box empty to leave it alone.', 'flxb.rxAnt': 'RX antenna', 'flxb.txAnt': 'TX antenna', 'flxb.noRadio': 'No antennas reported yet \u2014 connect the FlexRadio, then reopen this panel.', 'flxpw.phone': 'Phone', 'flxpw.digi': 'Digital', 'flxpw.noBands': 'No bands configured \u2014 add them in Lists \u2192 Bands.', 'flxpw.saved': 'Saved',
|
||||||
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
||||||
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
||||||
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
||||||
@@ -298,7 +298,7 @@ const en: Dict = {
|
|||||||
'es.usernameCall': 'Username (callsign)', 'es.eqslUserPh': 'your eQSL.cc login (callsign)', 'es.eqslPwPh': 'eQSL.cc account password', 'es.qthNick': 'QTH nickname', 'es.qthNickPh': 'optional — required only if your eQSL account has several QTHs', 'es.eqslHint': 'The QTH nickname tells eQSL which location profile to file the QSO under. Leave blank if you have only one.',
|
'es.usernameCall': 'Username (callsign)', 'es.eqslUserPh': 'your eQSL.cc login (callsign)', 'es.eqslPwPh': 'eQSL.cc account password', 'es.qthNick': 'QTH nickname', 'es.qthNickPh': 'optional — required only if your eQSL account has several QTHs', 'es.eqslHint': 'The QTH nickname tells eQSL which location profile to file the QSO under. Leave blank if you have only one.',
|
||||||
'es.lotwUser': 'LoTW user', 'es.lotwUserPh': 'LoTW website login (for downloading confirmations)', 'es.lotwPw': 'LoTW password', 'es.lotwPwPh': 'LoTW website password', 'es.tqslPath': 'TQSL path', 'es.stationLoc': 'Station location', 'es.pickLoc': '— pick a TQSL location —', 'es.noLoc': 'No TQSL locations found', 'es.reloadLoc': 'Reload locations from TQSL', 'es.lotwForcePh': "e.g. F4BPO/P — leave blank to use the QSO's own call", 'es.lotwForceHint': 'Overrides STATION_CALLSIGN at sign time so one certificate can sign several calls (F4BPO, F4BPO/P, TM2Q). Pick the matching Station Location above.', 'es.keyPw': 'Key password', 'es.keyPwPh': 'only if your certificate key has a password', 'es.considerUnsent': 'Consider as unsent', 'es.flagN': 'No (N)', 'es.flagR': 'Requested (R)', 'es.lotwUnsentHint': "At app close, every QSO whose LoTW sent status is one of these is signed and uploaded in one TQSL batch — including QSOs imported from an ADIF. Uploaded QSOs become Y and won't be re-sent. Must include your default sent status from Confirmations.", 'es.lotwAutoClose': 'Automatic upload on application close', 'es.lotwWriteLog': 'Write TQSL diagnostic log (-t)',
|
'es.lotwUser': 'LoTW user', 'es.lotwUserPh': 'LoTW website login (for downloading confirmations)', 'es.lotwPw': 'LoTW password', 'es.lotwPwPh': 'LoTW website password', 'es.tqslPath': 'TQSL path', 'es.stationLoc': 'Station location', 'es.pickLoc': '— pick a TQSL location —', 'es.noLoc': 'No TQSL locations found', 'es.reloadLoc': 'Reload locations from TQSL', 'es.lotwForcePh': "e.g. F4BPO/P — leave blank to use the QSO's own call", 'es.lotwForceHint': 'Overrides STATION_CALLSIGN at sign time so one certificate can sign several calls (F4BPO, F4BPO/P, TM2Q). Pick the matching Station Location above.', 'es.keyPw': 'Key password', 'es.keyPwPh': 'only if your certificate key has a password', 'es.considerUnsent': 'Consider as unsent', 'es.flagN': 'No (N)', 'es.flagR': 'Requested (R)', 'es.lotwUnsentHint': "At app close, every QSO whose LoTW sent status is one of these is signed and uploaded in one TQSL batch — including QSOs imported from an ADIF. Uploaded QSOs become Y and won't be re-sent. Must include your default sent status from Confirmations.", 'es.lotwAutoClose': 'Automatic upload on application close', 'es.lotwWriteLog': 'Write TQSL diagnostic log (-t)',
|
||||||
'es.potaHint': 'Update your QSOs with the park reference from your pota.app hunter log. Paste your session token: log in at pota.app, open the browser DevTools → Network tab, click any api.pota.app request, and copy the full Authorization header value. The token expires after a while — re-copy it if the sync fails.', 'es.sessionToken': 'Session token', 'es.saving': 'Saving…', 'es.saveToken': 'Save token', 'es.potaThen': 'Then run the sync from the QSL Manager tab → service POTA hunter log (you can see and fix unmatched QSOs there).',
|
'es.potaHint': 'Update your QSOs with the park reference from your pota.app hunter log. Paste your session token: log in at pota.app, open the browser DevTools → Network tab, click any api.pota.app request, and copy the full Authorization header value. The token expires after a while — re-copy it if the sync fails.', 'es.sessionToken': 'Session token', 'es.saving': 'Saving…', 'es.saveToken': 'Save token', 'es.potaThen': 'Then run the sync from the QSL Manager tab → service POTA hunter log (you can see and fix unmatched QSOs there).',
|
||||||
'es.soon': 'soon', 'es.comingSoon': '{name} — coming soon', 'es.notWired': "This external service isn't wired up yet.",
|
'es.soon': 'soon', 'es.deleteRemote': 'Also delete from QRZ.com and Club Log', 'es.deleteRemoteHint': 'When you delete a QSO here, withdraw it from those services too. QRZ.com can only delete records OpsLog uploaded itself \u2014 older ones must be removed on the website.', 'es.comingSoon': '{name} — coming soon', 'es.notWired': "This external service isn't wired up yet.",
|
||||||
'lotw.usersTitle': 'LoTW user list', 'lotw.usersHint': "Downloads ARRL's public list of LoTW users + their last-upload date, to show a colour-coded LoTW badge next to a callsign (green < 1 week · amber 1–4 weeks · red > 30 days).", 'lotw.usersDownload': 'Download LoTW user list', 'lotw.usersDownloading': 'Downloading…', 'lotw.usersLoaded': '{n} users loaded · updated {date}', 'lotw.usersNone': 'Not downloaded yet — the badge stays hidden until you do.',
|
'lotw.usersTitle': 'LoTW user list', 'lotw.usersHint': "Downloads ARRL's public list of LoTW users + their last-upload date, to show a colour-coded LoTW badge next to a callsign (green < 1 week · amber 1–4 weeks · red > 30 days).", 'lotw.usersDownload': 'Download LoTW user list', 'lotw.usersDownloading': 'Downloading…', 'lotw.usersLoaded': '{n} users loaded · updated {date}', 'lotw.usersNone': 'Not downloaded yet — the badge stays hidden until you do.',
|
||||||
'scp.title': 'Super Check Partial', 'scp.partial': 'Partial', 'scp.close': 'Close', 'scp.fill': 'Fill {call}', 'scp.none': 'No match', 'scp.typeMore': 'Type a call…', 'scp.noList': 'Download the SCP list in Settings → General.', 'scp.enable': 'Super Check Partial / N+1 callsign helper', 'scp.settingsHint': 'Downloads the community MASTER.SCP master list and shows a two-column widget as you type a call: Partial (known calls containing what you typed) and N+1 (calls one character away) — to spot and fix a busted call.', 'scp.download': 'Update SCP list', 'scp.downloading': 'Downloading…', 'scp.loaded': '{n} calls loaded · updated {date}', 'scp.notLoaded': 'Not downloaded yet.',
|
'scp.title': 'Super Check Partial', 'scp.partial': 'Partial', 'scp.close': 'Close', 'scp.fill': 'Fill {call}', 'scp.none': 'No match', 'scp.typeMore': 'Type a call…', 'scp.noList': 'Download the SCP list in Settings → General.', 'scp.enable': 'Super Check Partial / N+1 callsign helper', 'scp.settingsHint': 'Downloads the community MASTER.SCP master list and shows a two-column widget as you type a call: Partial (known calls containing what you typed) and N+1 (calls one character away) — to spot and fix a busted call.', 'scp.download': 'Update SCP list', 'scp.downloading': 'Downloading…', 'scp.loaded': '{n} calls loaded · updated {date}', 'scp.notLoaded': 'Not downloaded yet.',
|
||||||
'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)',
|
'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)',
|
||||||
@@ -366,14 +366,14 @@ const en: Dict = {
|
|||||||
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'mic-pass order · ⬆⬇ to reorder · double-click → edit · "Log & end" to save', 'ncp.moveUp': 'Move up the mic-pass order', 'ncp.moveDown': 'Move down the mic-pass order', 'ncp.logEndSelected': 'Log & end selected', 'ncp.logAll': 'Log everyone ({n})', 'ncp.logAllConfirm': 'Log all {n} on-air station(s) to the logbook?', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
|
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'mic-pass order · ⬆⬇ to reorder · double-click → edit · "Log & end" to save', 'ncp.moveUp': 'Move up the mic-pass order', 'ncp.moveDown': 'Move down the mic-pass order', 'ncp.logEndSelected': 'Log & end selected', 'ncp.logAll': 'Log everyone ({n})', 'ncp.logAllConfirm': 'Log all {n} on-air station(s) to the logbook?', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
|
||||||
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as <PST><FREQUENCY> whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save',
|
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as <PST><FREQUENCY> whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save',
|
||||||
'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close',
|
'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close',
|
||||||
'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ zone', 'detp.ituZone': 'ITU zone', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email',
|
'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.newCounty': 'NEW', 'detp.newCountyTip': 'County never worked before', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ', 'detp.ituZone': 'ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.ambiguous': 'Ambiguous — pick one:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email',
|
||||||
// Awards (ref picker / ref selector / awards panel / award editor)
|
// Awards (ref picker / ref selector / awards panel / award editor)
|
||||||
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
|
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
|
||||||
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
|
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
|
||||||
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.modePhone': 'Phone', 'awp.modeDigital': 'Digital', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
||||||
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
|
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.oneRef': 'One reference per QSO', 'awed.oneRefHint': 'When several references match the same contact, assign none and list it under missing references. For awards where two entries can share a description — two DOKs called Gießen — picking one at random would write the wrong one into the log.', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
|
||||||
'awed.updateAvailable': 'An updated version of this award is available', 'awed.updateOverwrites': 'You have modified this award, so the update was not applied. Taking it replaces your definition and reference list.', 'awed.updateApply': 'Update', 'awed.updateKeepMine': 'Keep mine',
|
'awed.updateAvailable': 'An updated version of this award is available', 'awed.updateOverwrites': 'You have modified this award, so the update was not applied. Taking it replaces your definition and reference list.', 'awed.updateApply': 'Update', 'awed.updateKeepMine': 'Keep mine',
|
||||||
'awed.tabTest': 'Test', 'awed.testCallsign': 'Test against callsign', 'awed.testRun': 'Test', 'awed.testSavedOnly': 'Tests the SAVED award — save your changes first.', 'awed.testNoMatch': 'no match', 'awed.testOutOfScope': 'QSO out of scope — no rule was run.', 'awed.testSkipped': 'not run: an earlier rule already matched', 'awed.testFieldValue': 'Field', 'awed.testEmptyField': 'empty', 'awed.testNoCandidate': 'produced no candidate', 'awed.testManual': 'Manual override', 'awed.testSameAs': '+{n} other QSO(s), same result',
|
'awed.tabTest': 'Test', 'awed.testCallsign': 'Test against callsign', 'awed.testRun': 'Test', 'awed.testSavedOnly': 'Tests the SAVED award — save your changes first.', 'awed.testNoMatch': 'no match', 'awed.testOutOfScope': 'QSO out of scope — no rule was run.', 'awed.testSkipped': 'not run: an earlier rule already matched', 'awed.testFieldValue': 'Field', 'awed.testEmptyField': 'empty', 'awed.testNoCandidate': 'produced no candidate', 'awed.testManual': 'Manual override', 'awed.testAmbiguous': 'Ambiguous', 'awed.testAmbiguousHint': '— none kept, this award allows one reference per QSO. Assign the right one by hand.', 'awed.testSameAs': '+{n} other QSO(s), same result',
|
||||||
'awed.exportOne': 'Share {code}',
|
'awed.exportOne': 'Share {code}',
|
||||||
'awed.onlyHere': 'local',
|
'awed.onlyHere': 'local',
|
||||||
'awed.onlyHereTip': 'Yours — not shipped with OpsLog. Its JSON is kept up to date in the awards folder; send that file to share it.',
|
'awed.onlyHereTip': 'Yours — not shipped with OpsLog. Its JSON is kept up to date in the awards folder; send that file to share it.',
|
||||||
@@ -398,7 +398,7 @@ const en: Dict = {
|
|||||||
'awed.importReplace': 'Replace mine',
|
'awed.importReplace': 'Replace mine',
|
||||||
'awed.importCopy': 'Import as {code}-2',
|
'awed.importCopy': 'Import as {code}-2',
|
||||||
// QSO modals (context menu / bulk edit / QSL manager / QSO edit)
|
// QSO modals (context menu / bulk edit / QSL manager / QSO edit)
|
||||||
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportSelectedFields': 'Export selected — choose fields… ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
|
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from the callsign databases', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportSelectedFields': 'Export selected — choose fields… ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
|
||||||
'exp.title': 'Export to ADIF', 'exp.desc': 'Choose which fields to write.',
|
'exp.title': 'Export to ADIF', 'exp.desc': 'Choose which fields to write.',
|
||||||
'exp.stdTitle': 'Standard ADIF fields', 'exp.stdDesc': 'Official ADIF 3.1.7 fields only — best for uploading to other logbooks (LoTW, QRZ, Club Log…).',
|
'exp.stdTitle': 'Standard ADIF fields', 'exp.stdDesc': 'Official ADIF 3.1.7 fields only — best for uploading to other logbooks (LoTW, QRZ, Club Log…).',
|
||||||
'exp.allTitle': 'All OpsLog fields', 'exp.allDesc': 'Everything, including OpsLog-specific and app-defined tags — a full backup you can re-import here.',
|
'exp.allTitle': 'All OpsLog fields', 'exp.allDesc': 'Everything, including OpsLog-specific and app-defined tags — a full backup you can re-import here.',
|
||||||
@@ -424,7 +424,7 @@ const en: Dict = {
|
|||||||
'aud.txOn': 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.', 'aud.txHint': 'Live mic → rig with PTT (USB now; network TX later).',
|
'aud.txOn': 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.', 'aud.txHint': 'Live mic → rig with PTT (USB now; network TX later).',
|
||||||
'aud.recorder': 'QSO recorder', 'aud.recordEvery': 'Record every QSO to an audio file (From Radio + your mic)', 'aud.recFolder': 'Recordings folder', 'aud.browse': 'Browse…',
|
'aud.recorder': 'QSO recorder', 'aud.recordEvery': 'Record every QSO to an audio file (From Radio + your mic)', 'aud.recFolder': 'Recordings folder', 'aud.browse': 'Browse…',
|
||||||
'aud.preroll': 'Pre-roll (seconds)', 'aud.format': 'File format', 'aud.wav': 'WAV (lossless, larger)', 'aud.mp3': 'MP3 (compressed, small)',
|
'aud.preroll': 'Pre-roll (seconds)', 'aud.format': 'File format', 'aud.wav': 'WAV (lossless, larger)', 'aud.mp3': 'MP3 (compressed, small)',
|
||||||
'aud.fromLevel': 'From Radio level', 'aud.txLevel': 'Voice keyer level', 'aud.txLevelHint': 'Level of the recorded messages sent to the radio. Raise it if your voice keyer is much quieter than your microphone; Play previews at this same level. If the radio transmits almost nothing, its modulation source is still the front microphone: on an FTDX10 set MENU → SSB MOD SOURCE to REAR (the USB input).', 'aud.micLevel': 'Mic level', 'aud.levelHint': 'If your voice is louder than the station, lower Mic level.',
|
'aud.fromLevel': 'From Radio level', 'aud.txLevel': 'Voice keyer level', 'aud.txLevelHint': 'Level of the recorded messages sent to the radio. Raise it if your voice keyer is much quieter than your microphone; Play previews at this same level. If the radio transmits almost nothing, its modulation source is still the front microphone: on an FTDX10 set MENU → SSB MOD SOURCE to REAR (the USB input).', 'aud.micLevel': 'Mic level', 'aud.qsoPlayLevel': 'QSO playback level', 'aud.levelHint': 'If your voice is louder than the station, lower Mic level.',
|
||||||
'aud.autoSend': 'Auto-send the recording to the station by e-mail when I log a QSO',
|
'aud.autoSend': 'Auto-send the recording to the station by e-mail when I log a QSO',
|
||||||
'aud.dvkTitle': 'Voice keyer messages (F1–F6)', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR',
|
'aud.dvkTitle': 'Voice keyer messages (F1–F6)', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR',
|
||||||
'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh',
|
'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh',
|
||||||
@@ -475,7 +475,7 @@ const fr: Dict = {
|
|||||||
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
||||||
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.',
|
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.',
|
||||||
'upd.checking': 'Recherche de mises à jour…', 'upd.upToDate': 'Vous êtes à jour',
|
'upd.checking': 'Recherche de mises à jour…', 'upd.upToDate': 'Vous êtes à jour',
|
||||||
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
'settings.language': 'Langue', 'gen.dateFormat': 'Affichage des dates', 'gen.dateStandard': 'Standard', 'gen.dateFR': 'Fran\u00e7ais', 'gen.dateUS': 'US', 'settings.languageHint': "Langue de l'interface.",
|
||||||
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
|
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
|
||||||
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
|
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
|
||||||
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
|
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
|
||||||
@@ -545,9 +545,9 @@ const fr: Dict = {
|
|||||||
'imp.ctyTitle': 'Corriger pays & zones (cty.dat + ClubLog)',
|
'imp.ctyTitle': 'Corriger pays & zones (cty.dat + ClubLog)',
|
||||||
'imp.ctyDesc': "Recalcule Pays, DXCC & zones CQ/ITU depuis cty.dat, en écrasant le fichier — corrige ce que les logiciels de contest exportent mal (ex. RG2Y en Russie asiatique au lieu d'européenne). Les exceptions DXpédition de ClubLog s'appliquent par-dessus selon la date du QSO (ex. TO974REF → Réunion, TO2A 2012 → Guyane française) dès que les données ClubLog sont téléchargées. Tout le reste de l'ADIF est conservé tel quel. Astuce : utilise Mettre à jour les doublons pour re-corriger des QSO déjà dans le log.",
|
'imp.ctyDesc': "Recalcule Pays, DXCC & zones CQ/ITU depuis cty.dat, en écrasant le fichier — corrige ce que les logiciels de contest exportent mal (ex. RG2Y en Russie asiatique au lieu d'européenne). Les exceptions DXpédition de ClubLog s'appliquent par-dessus selon la date du QSO (ex. TO974REF → Réunion, TO2A 2012 → Guyane française) dès que les données ClubLog sont téléchargées. Tout le reste de l'ADIF est conservé tel quel. Astuce : utilise Mettre à jour les doublons pour re-corriger des QSO déjà dans le log.",
|
||||||
'imp.stationTitle': 'Remplir mes champs station depuis mon profil',
|
'imp.stationTitle': 'Remplir mes champs station depuis mon profil',
|
||||||
'imp.stationDesc': "Complète les champs MY_* vides (locator, rig, antenne, adresse, ville, état, comté, réf. SOTA/POTA, puissance TX…) plus Opérateur et Indicatif propriétaire depuis le profil actif. Les valeurs existantes sont conservées. Seul STATION_CALLSIGN n'est jamais touché pour ne pas re-router un log multi-indicatifs. À activer quand tu importes ton propre log.",
|
'imp.stationDesc': "Complète les champs MY_* vides (locator, rig, antenne, adresse, ville, état, comté, réf. SOTA/POTA, puissance TX…) plus Opérateur et Indicatif propriétaire depuis le profil actif. Les valeurs existantes sont conservées. Seul STATION_CALLSIGN n'est jamais touché pour ne pas re-router un log multi-indicatifs. Elle applique AUSSI tes statuts de confirmation par défaut (QSL papier, LoTW, eQSL, Club Log, HRDLog, QRZ.com envoyé et reçu) sur ceux que le fichier laisse vides — un log WSJT-X n'en contient pratiquement aucun. À activer quand tu importes ton propre log.",
|
||||||
'imp.cancel': 'Annuler', 'imp.import': 'Importer',
|
'imp.cancel': 'Annuler', 'rec.manualStart': 'Enregistrer ce contact. L\u2019enregistrement automatique est d\u00e9sactiv\u00e9 : la capture commence maintenant, sans les secondes qui pr\u00e9c\u00e8dent.', 'rec.stop': 'Arr\u00eater l\u2019enregistrement. L\u2019audio est conserv\u00e9 et sera enregistr\u00e9 avec le QSO \u2014 arr\u00eatez-le pour le repasser \u00e0 la station travaill\u00e9e.', 'rec.resume': 'Reprendre l\u2019enregistrement, \u00e0 la suite de ce qui est d\u00e9j\u00e0 captur\u00e9.', 'rec.playOnAir': '\u00c9METTRE l\u2019enregistrement vers la station travaill\u00e9e : passe la radio en \u00e9mission et l\u2019envoie comme un message du manipulateur vocal.', 'rec.stopPlaying': 'Arrêter l’émission de l’enregistrement', 'rec.manualFailed': 'L\u2019enregistrement n\u2019a pas pu d\u00e9marrer \u2014 v\u00e9rifiez les p\u00e9riph\u00e9riques audio dans les R\u00e9glages.', 'imp.mapAdd': 'Le fichier met un champ au mauvais endroit\u2026', 'imp.mapTitle': 'D\u00e9placer des champs \u00e0 l\u2019import', 'imp.mapDesc': 'Les logiciels de concours rangent l\u2019\u00e9change l\u00e0 o\u00f9 leur module le garde. Le contest IOTA de la RSGB exporte la r\u00e9f\u00e9rence d\u2019\u00eele dans STATE \u2014 import\u00e9e telle quelle elle devient un \u00e9tat am\u00e9ricain et le dipl\u00f4me IOTA reste vide. La destination n\u2019est remplie que l\u00e0 o\u00f9 le fichier l\u2019a laiss\u00e9e vide.', 'imp.mapMore': 'Ajouter une ligne', 'imp.import': 'Importer',
|
||||||
'imp.progressTitle': 'Import ADIF en cours…',
|
'imp.progressTitle': 'Import ADIF en cours…', 'bulk.progressTitle': 'Mise \u00e0 jour des QSO s\u00e9lectionn\u00e9s\u2026',
|
||||||
'imp.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…',
|
'imp.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…',
|
||||||
'imp.complete': 'Import terminé.',
|
'imp.complete': 'Import terminé.',
|
||||||
'imp.imported': '{n} importé(s)', 'imp.updated': '{n} mis à jour', 'imp.duplicates': '{n} doublon(s)', 'imp.skipped': '{n} ignoré(s)', 'imp.total': '{n} au total',
|
'imp.imported': '{n} importé(s)', 'imp.updated': '{n} mis à jour', 'imp.duplicates': '{n} doublon(s)', 'imp.skipped': '{n} ignoré(s)', 'imp.total': '{n} au total',
|
||||||
@@ -675,10 +675,10 @@ const fr: Dict = {
|
|||||||
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
|
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
|
||||||
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||||
'tg2.hint': "OpsLog dialogue avec le 4O3A Tuner Genius XL en TCP (port fixé à 9010), seule l'IP de l'appareil est nécessaire. Un widget ancré affiche le ROS et la puissance directe et propose Accord, Bypass et Operate/Standby. Pilotage direct (pas via la radio) pour n'utiliser qu'une des connexions de la boîte.", 'tg2.enable': "Activer le contrôle du Tuner Genius", 'tg2.portHint': "Le port TCP est fixé à 9010 sur l'appareil.", 'tg2.password': 'Code distant', 'tg2.passwordPh': 'vide en LAN', 'tg2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
'tg2.hint': "OpsLog dialogue avec le 4O3A Tuner Genius XL en TCP (port fixé à 9010), seule l'IP de l'appareil est nécessaire. Un widget ancré affiche le ROS et la puissance directe et propose Accord, Bypass et Operate/Standby. Pilotage direct (pas via la radio) pour n'utiliser qu'une des connexions de la boîte.", 'tg2.enable': "Activer le contrôle du Tuner Genius", 'tg2.portHint': "Le port TCP est fixé à 9010 sur l'appareil.", 'tg2.password': 'Code distant', 'tg2.passwordPh': 'vide en LAN', 'tg2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||||
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à un microHAM ARCO — sans PstRotator. Réseau : régler Config → LAN → CONTROL PROTOCOL sur « Yaesu GS-232A » sur l'ARCO et saisir ici le même port TCP (jusqu'à 4 connexions en parallèle). USB : régler USB CONTROL PROTOCOL sur « Yaesu GS-232A » et choisir ici le port COM de l'ARCO (la vitesse est sans importance en USB).", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
||||||
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
||||||
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
||||||
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.optXiegu': 'Xiegu (CI-V natif)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.optYaesu': 'Yaesu (CAT natif)', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.xieguPTTLine': 'Passage en \u00e9mission', 'cat.xieguPTTCiv': 'Commande CI-V', 'cat.xieguPTTHint': 'Un G90 ne passe pas en \u00e9mission sur la commande CI-V : les interfaces comme le DE-19 le pilotent par RTS ou DTR. Choisissez la ligne de la v\u00f4tre \u2014 c\u2019est aussi ce qui permet \u00e0 WSJT-X d\u2019\u00e9mettre via le partage CAT.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 puissance par bande et par mode', 'flxpw.hint': 'Puissance d\u2019\u00e9mission appliqu\u00e9e au changement de bande ou de mode. Laissez une case vide pour ne pas toucher \u00e0 la puissance.', 'flxpw.band': 'Bande', 'flxb.title': 'FlexRadio \u2014 antennes et puissance par bande', 'flxb.hint': 'Les antennes sont appliqu\u00e9es au changement de bande ; la puissance au changement de bande ou de mode. Laissez une case de puissance vide pour ne pas y toucher.', 'flxb.rxAnt': 'Antenne RX', 'flxb.txAnt': 'Antenne TX', 'flxb.noRadio': 'Aucune antenne remont\u00e9e \u2014 connectez le FlexRadio, puis rouvrez ce panneau.', 'flxpw.phone': 'Phonie', 'flxpw.digi': 'Num\u00e9rique', 'flxpw.noBands': 'Aucune bande configur\u00e9e \u2014 ajoutez-les dans Listes \u2192 Bandes.', 'flxpw.saved': 'Enregistr\u00e9',
|
||||||
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
||||||
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
||||||
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||||
@@ -698,7 +698,7 @@ const fr: Dict = {
|
|||||||
'es.usernameCall': 'Identifiant (indicatif)', 'es.eqslUserPh': 'ton identifiant eQSL.cc (indicatif)', 'es.eqslPwPh': 'mot de passe du compte eQSL.cc', 'es.qthNick': 'Surnom QTH', 'es.qthNickPh': 'optionnel — requis seulement si ton compte eQSL a plusieurs QTH', 'es.eqslHint': "Le surnom QTH indique à eQSL sous quel profil de lieu classer le QSO. Laisse vide si tu n'en as qu'un.",
|
'es.usernameCall': 'Identifiant (indicatif)', 'es.eqslUserPh': 'ton identifiant eQSL.cc (indicatif)', 'es.eqslPwPh': 'mot de passe du compte eQSL.cc', 'es.qthNick': 'Surnom QTH', 'es.qthNickPh': 'optionnel — requis seulement si ton compte eQSL a plusieurs QTH', 'es.eqslHint': "Le surnom QTH indique à eQSL sous quel profil de lieu classer le QSO. Laisse vide si tu n'en as qu'un.",
|
||||||
'es.lotwUser': 'Utilisateur LoTW', 'es.lotwUserPh': 'identifiant du site LoTW (pour télécharger les confirmations)', 'es.lotwPw': 'Mot de passe LoTW', 'es.lotwPwPh': 'mot de passe du site LoTW', 'es.tqslPath': 'Chemin TQSL', 'es.stationLoc': 'Station location', 'es.pickLoc': '— choisir une Station Location TQSL —', 'es.noLoc': 'Aucune Station Location TQSL trouvée', 'es.reloadLoc': 'Recharger les locations depuis TQSL', 'es.lotwForcePh': "p. ex. F4BPO/P — laisse vide pour utiliser l'indicatif du QSO", 'es.lotwForceHint': "Remplace STATION_CALLSIGN à la signature pour qu'un même certificat signe plusieurs indicatifs (F4BPO, F4BPO/P, TM2Q). Choisis la Station Location correspondante ci-dessus.", 'es.keyPw': 'Mot de passe de la clé', 'es.keyPwPh': 'seulement si la clé de ton certificat a un mot de passe', 'es.considerUnsent': 'Considérer comme non envoyé', 'es.flagN': 'Non (N)', 'es.flagR': 'Demandé (R)', 'es.lotwUnsentHint': "À la fermeture, chaque QSO dont le statut LoTW « envoyé » est l'un de ceux-ci est signé et envoyé dans un même lot TQSL — y compris les QSO importés depuis un ADIF. Les QSO envoyés passent à Y et ne sont pas renvoyés. Doit inclure ton statut « envoyé » par défaut défini dans Confirmations.", 'es.lotwAutoClose': "Envoi automatique à la fermeture de l'application", 'es.lotwWriteLog': 'Écrire le journal de diagnostic TQSL (-t)',
|
'es.lotwUser': 'Utilisateur LoTW', 'es.lotwUserPh': 'identifiant du site LoTW (pour télécharger les confirmations)', 'es.lotwPw': 'Mot de passe LoTW', 'es.lotwPwPh': 'mot de passe du site LoTW', 'es.tqslPath': 'Chemin TQSL', 'es.stationLoc': 'Station location', 'es.pickLoc': '— choisir une Station Location TQSL —', 'es.noLoc': 'Aucune Station Location TQSL trouvée', 'es.reloadLoc': 'Recharger les locations depuis TQSL', 'es.lotwForcePh': "p. ex. F4BPO/P — laisse vide pour utiliser l'indicatif du QSO", 'es.lotwForceHint': "Remplace STATION_CALLSIGN à la signature pour qu'un même certificat signe plusieurs indicatifs (F4BPO, F4BPO/P, TM2Q). Choisis la Station Location correspondante ci-dessus.", 'es.keyPw': 'Mot de passe de la clé', 'es.keyPwPh': 'seulement si la clé de ton certificat a un mot de passe', 'es.considerUnsent': 'Considérer comme non envoyé', 'es.flagN': 'Non (N)', 'es.flagR': 'Demandé (R)', 'es.lotwUnsentHint': "À la fermeture, chaque QSO dont le statut LoTW « envoyé » est l'un de ceux-ci est signé et envoyé dans un même lot TQSL — y compris les QSO importés depuis un ADIF. Les QSO envoyés passent à Y et ne sont pas renvoyés. Doit inclure ton statut « envoyé » par défaut défini dans Confirmations.", 'es.lotwAutoClose': "Envoi automatique à la fermeture de l'application", 'es.lotwWriteLog': 'Écrire le journal de diagnostic TQSL (-t)',
|
||||||
'es.potaHint': "Met à jour tes QSO avec la référence du parc depuis ton carnet chasseur pota.app. Colle ton jeton de session : connecte-toi sur pota.app, ouvre les DevTools du navigateur → onglet Network, clique sur une requête api.pota.app, et copie toute la valeur de l'en-tête Authorization. Le jeton expire au bout d'un moment — recopie-le si la synchro échoue.", 'es.sessionToken': 'Jeton de session', 'es.saving': 'Enregistrement…', 'es.saveToken': 'Enregistrer le jeton', 'es.potaThen': "Puis lance la synchro depuis l'onglet Gestionnaire QSL → service POTA hunter log (tu peux y voir et corriger les QSO non appariés).",
|
'es.potaHint': "Met à jour tes QSO avec la référence du parc depuis ton carnet chasseur pota.app. Colle ton jeton de session : connecte-toi sur pota.app, ouvre les DevTools du navigateur → onglet Network, clique sur une requête api.pota.app, et copie toute la valeur de l'en-tête Authorization. Le jeton expire au bout d'un moment — recopie-le si la synchro échoue.", 'es.sessionToken': 'Jeton de session', 'es.saving': 'Enregistrement…', 'es.saveToken': 'Enregistrer le jeton', 'es.potaThen': "Puis lance la synchro depuis l'onglet Gestionnaire QSL → service POTA hunter log (tu peux y voir et corriger les QSO non appariés).",
|
||||||
'es.soon': 'bientôt', 'es.comingSoon': '{name} — bientôt', 'es.notWired': "Ce service externe n'est pas encore branché.",
|
'es.soon': 'bientôt', 'es.deleteRemote': 'Supprimer aussi sur QRZ.com et Club Log', 'es.deleteRemoteHint': 'Quand vous supprimez un QSO ici, le retirer aussi de ces services. QRZ.com ne peut supprimer que les enregistrements envoy\u00e9s par OpsLog \u2014 les plus anciens doivent \u00eatre retir\u00e9s sur le site.', 'es.comingSoon': '{name} — bientôt', 'es.notWired': "Ce service externe n'est pas encore branché.",
|
||||||
'lotw.usersTitle': 'Liste des utilisateurs LoTW', 'lotw.usersHint': "Télécharge la liste publique ARRL des utilisateurs LoTW + leur date de dernier upload, pour afficher un badge LoTW coloré à côté d'un indicatif (vert < 1 semaine · ambre 1–4 semaines · rouge > 30 j).", 'lotw.usersDownload': 'Télécharger la liste LoTW', 'lotw.usersDownloading': 'Téléchargement…', 'lotw.usersLoaded': '{n} utilisateurs chargés · maj {date}', 'lotw.usersNone': 'Pas encore téléchargée — le badge reste caché tant que ce n’est pas fait.',
|
'lotw.usersTitle': 'Liste des utilisateurs LoTW', 'lotw.usersHint': "Télécharge la liste publique ARRL des utilisateurs LoTW + leur date de dernier upload, pour afficher un badge LoTW coloré à côté d'un indicatif (vert < 1 semaine · ambre 1–4 semaines · rouge > 30 j).", 'lotw.usersDownload': 'Télécharger la liste LoTW', 'lotw.usersDownloading': 'Téléchargement…', 'lotw.usersLoaded': '{n} utilisateurs chargés · maj {date}', 'lotw.usersNone': 'Pas encore téléchargée — le badge reste caché tant que ce n’est pas fait.',
|
||||||
'scp.title': 'Super Check Partial', 'scp.partial': 'Partiel', 'scp.close': 'Fermer', 'scp.fill': 'Remplir {call}', 'scp.none': 'Aucun', 'scp.typeMore': 'Tape un indicatif…', 'scp.noList': 'Télécharge la liste SCP dans Réglages → Général.', 'scp.enable': 'Assistant indicatifs Super Check Partial / N+1', 'scp.settingsHint': "Télécharge la liste communautaire MASTER.SCP et affiche un widget en 2 colonnes pendant que tu tapes un indicatif : Partiel (indicatifs connus contenant ce que tu tapes) et N+1 (indicatifs à une lettre près) — pour repérer et corriger un call busté.", 'scp.download': 'Mettre à jour la liste SCP', 'scp.downloading': 'Téléchargement…', 'scp.loaded': '{n} indicatifs chargés · maj {date}', 'scp.notLoaded': 'Pas encore téléchargée.',
|
'scp.title': 'Super Check Partial', 'scp.partial': 'Partiel', 'scp.close': 'Fermer', 'scp.fill': 'Remplir {call}', 'scp.none': 'Aucun', 'scp.typeMore': 'Tape un indicatif…', 'scp.noList': 'Télécharge la liste SCP dans Réglages → Général.', 'scp.enable': 'Assistant indicatifs Super Check Partial / N+1', 'scp.settingsHint': "Télécharge la liste communautaire MASTER.SCP et affiche un widget en 2 colonnes pendant que tu tapes un indicatif : Partiel (indicatifs connus contenant ce que tu tapes) et N+1 (indicatifs à une lettre près) — pour repérer et corriger un call busté.", 'scp.download': 'Mettre à jour la liste SCP', 'scp.downloading': 'Téléchargement…', 'scp.loaded': '{n} indicatifs chargés · maj {date}', 'scp.notLoaded': 'Pas encore téléchargée.',
|
||||||
'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)',
|
'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)',
|
||||||
@@ -760,13 +760,13 @@ const fr: Dict = {
|
|||||||
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Déjà contacté', 'ncp.wbHint': 'Cliquer une station (on air ou roster) pour voir les QSO précédents', 'ncp.wbNone': 'Aucun QSO précédent avec', 'ncp.wbFirst': 'premier', 'ncp.wbLast': 'dernier', 'ncp.wbResize': 'Glisser pour redimensionner', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'ordre de passage du micro · ⬆⬇ pour réordonner · double-clic → éditer · « Logger & terminer »', 'ncp.moveUp': "Monter dans l'ordre de passage", 'ncp.moveDown': "Descendre dans l'ordre de passage", 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.logAll': 'Logger tout le monde ({n})', 'ncp.logAllConfirm': 'Logger les {n} station(s) on air dans le logbook ?', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
|
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Déjà contacté', 'ncp.wbHint': 'Cliquer une station (on air ou roster) pour voir les QSO précédents', 'ncp.wbNone': 'Aucun QSO précédent avec', 'ncp.wbFirst': 'premier', 'ncp.wbLast': 'dernier', 'ncp.wbResize': 'Glisser pour redimensionner', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'ordre de passage du micro · ⬆⬇ pour réordonner · double-clic → éditer · « Logger & terminer »', 'ncp.moveUp': "Monter dans l'ordre de passage", 'ncp.moveDown': "Descendre dans l'ordre de passage", 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.logAll': 'Logger tout le monde ({n})', 'ncp.logAllConfirm': 'Logger les {n} station(s) on air dans le logbook ?', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
|
||||||
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en <PST><FREQUENCY> à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer',
|
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en <PST><FREQUENCY> à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer',
|
||||||
'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer',
|
'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer',
|
||||||
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
|
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.newCounty': 'NOUV', 'detp.newCountyTip': 'Comté jamais contacté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'CQ', 'detp.ituZone': 'ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.ambiguous': 'Ambigu — choisissez :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
|
||||||
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
|
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
|
||||||
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
|
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
|
||||||
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.modePhone': 'Phonie', 'awp.modeDigital': 'Numérique', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
||||||
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
|
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.oneRef': 'Une seule référence par QSO', 'awed.oneRefHint': 'Quand plusieurs références correspondent au même contact, n'+'’en affecter aucune et le lister dans les références manquantes. Pour les diplômes où deux entrées partagent une description — deux DOK nommés Gießen — en choisir une au hasard inscrirait la mauvaise dans le journal.', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
|
||||||
'awed.updateAvailable': 'Une nouvelle version de ce diplôme est disponible', 'awed.updateOverwrites': "Tu as modifié ce diplôme, la mise à jour n'a donc pas été appliquée. L'accepter remplacera ta définition et ta liste de références.", 'awed.updateApply': 'Mettre à jour', 'awed.updateKeepMine': 'Garder les miennes',
|
'awed.updateAvailable': 'Une nouvelle version de ce diplôme est disponible', 'awed.updateOverwrites': "Tu as modifié ce diplôme, la mise à jour n'a donc pas été appliquée. L'accepter remplacera ta définition et ta liste de références.", 'awed.updateApply': 'Mettre à jour', 'awed.updateKeepMine': 'Garder les miennes',
|
||||||
'awed.tabTest': 'Test', 'awed.testCallsign': 'Tester avec un indicatif', 'awed.testRun': 'Tester', 'awed.testSavedOnly': 'Teste le diplôme ENREGISTRÉ — enregistre tes modifications avant.', 'awed.testNoMatch': 'aucune correspondance', 'awed.testOutOfScope': "QSO hors périmètre — aucune règle n'a été exécutée.", 'awed.testSkipped': "non exécutée : une règle précédente a déjà trouvé", 'awed.testFieldValue': 'Champ', 'awed.testEmptyField': 'vide', 'awed.testNoCandidate': "n'a produit aucun candidat", 'awed.testManual': 'Référence forcée à la main', 'awed.testSameAs': '+{n} autre(s) QSO, même résultat',
|
'awed.tabTest': 'Test', 'awed.testCallsign': 'Tester avec un indicatif', 'awed.testRun': 'Tester', 'awed.testSavedOnly': 'Teste le diplôme ENREGISTRÉ — enregistre tes modifications avant.', 'awed.testNoMatch': 'aucune correspondance', 'awed.testOutOfScope': "QSO hors périmètre — aucune règle n'a été exécutée.", 'awed.testSkipped': "non exécutée : une règle précédente a déjà trouvé", 'awed.testFieldValue': 'Champ', 'awed.testEmptyField': 'vide', 'awed.testNoCandidate': "n'a produit aucun candidat", 'awed.testManual': 'Référence forcée à la main', 'awed.testAmbiguous': 'Ambigu', 'awed.testAmbiguousHint': '— aucune retenue, ce diplôme n’admet qu’une référence par QSO. Affectez la bonne à la main.', 'awed.testSameAs': '+{n} autre(s) QSO, même résultat',
|
||||||
'awed.exportOne': 'Partager {code}',
|
'awed.exportOne': 'Partager {code}',
|
||||||
'awed.onlyHere': 'local',
|
'awed.onlyHere': 'local',
|
||||||
'awed.onlyHereTip': 'À toi — non livré avec OpsLog. Son JSON est tenu à jour dans le dossier awards ; envoie ce fichier pour le partager.',
|
'awed.onlyHereTip': 'À toi — non livré avec OpsLog. Son JSON est tenu à jour dans le dossier awards ; envoie ce fichier pour le partager.',
|
||||||
@@ -790,7 +790,7 @@ const fr: Dict = {
|
|||||||
'awed.importKeepMine': 'Garder le mien',
|
'awed.importKeepMine': 'Garder le mien',
|
||||||
'awed.importReplace': 'Remplacer le mien',
|
'awed.importReplace': 'Remplacer le mien',
|
||||||
'awed.importCopy': 'Importer en {code}-2',
|
'awed.importCopy': 'Importer en {code}-2',
|
||||||
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
|
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis les annuaires', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
|
||||||
'exp.title': 'Exporter en ADIF', 'exp.desc': 'Choisissez les champs à écrire.',
|
'exp.title': 'Exporter en ADIF', 'exp.desc': 'Choisissez les champs à écrire.',
|
||||||
'exp.stdTitle': 'Champs ADIF standard', 'exp.stdDesc': 'Uniquement les champs officiels ADIF 3.1.7 — idéal pour l’envoi vers d’autres carnets (LoTW, QRZ, Club Log…).',
|
'exp.stdTitle': 'Champs ADIF standard', 'exp.stdDesc': 'Uniquement les champs officiels ADIF 3.1.7 — idéal pour l’envoi vers d’autres carnets (LoTW, QRZ, Club Log…).',
|
||||||
'exp.allTitle': 'Tous les champs OpsLog', 'exp.allDesc': 'Tout, y compris les champs spécifiques à OpsLog et les balises applicatives — une sauvegarde complète ré-importable ici.',
|
'exp.allTitle': 'Tous les champs OpsLog', 'exp.allDesc': 'Tout, y compris les champs spécifiques à OpsLog et les balises applicatives — une sauvegarde complète ré-importable ici.',
|
||||||
@@ -815,7 +815,7 @@ const fr: Dict = {
|
|||||||
'aud.txOn': 'ÉMISSION — micro → Vers la radio, PTT activé. Cliquez pour arrêter.', 'aud.txHint': "Micro direct → poste avec PTT (USB pour l'instant ; TX réseau plus tard).",
|
'aud.txOn': 'ÉMISSION — micro → Vers la radio, PTT activé. Cliquez pour arrêter.', 'aud.txHint': "Micro direct → poste avec PTT (USB pour l'instant ; TX réseau plus tard).",
|
||||||
'aud.recorder': 'Enregistreur de QSO', 'aud.recordEvery': 'Enregistrer chaque QSO dans un fichier audio (Depuis la radio + votre micro)', 'aud.recFolder': 'Dossier des enregistrements', 'aud.browse': 'Parcourir…',
|
'aud.recorder': 'Enregistreur de QSO', 'aud.recordEvery': 'Enregistrer chaque QSO dans un fichier audio (Depuis la radio + votre micro)', 'aud.recFolder': 'Dossier des enregistrements', 'aud.browse': 'Parcourir…',
|
||||||
'aud.preroll': 'Pré-enregistrement (secondes)', 'aud.format': 'Format de fichier', 'aud.wav': 'WAV (sans perte, plus volumineux)', 'aud.mp3': 'MP3 (compressé, léger)',
|
'aud.preroll': 'Pré-enregistrement (secondes)', 'aud.format': 'Format de fichier', 'aud.wav': 'WAV (sans perte, plus volumineux)', 'aud.mp3': 'MP3 (compressé, léger)',
|
||||||
'aud.fromLevel': 'Niveau depuis la radio', 'aud.txLevel': 'Niveau du voice keyer', 'aud.txLevelHint': "Niveau des messages enregistrés envoyés à la radio. Augmentez-le si votre voice keyer est bien plus faible que votre micro ; Lire fait entendre ce même niveau. Si la radio n'émet presque rien, c'est que sa source de modulation est restée le micro de façade : sur un FTDX10, réglez MENU → SSB MOD SOURCE sur REAR (l'entrée USB).", 'aud.micLevel': 'Niveau micro', 'aud.levelHint': 'Si votre voix est plus forte que la station, baissez le niveau micro.',
|
'aud.fromLevel': 'Niveau depuis la radio', 'aud.txLevel': 'Niveau du voice keyer', 'aud.txLevelHint': "Niveau des messages enregistrés envoyés à la radio. Augmentez-le si votre voice keyer est bien plus faible que votre micro ; Lire fait entendre ce même niveau. Si la radio n'émet presque rien, c'est que sa source de modulation est restée le micro de façade : sur un FTDX10, réglez MENU → SSB MOD SOURCE sur REAR (l'entrée USB).", 'aud.micLevel': 'Niveau micro', 'aud.qsoPlayLevel': 'Niveau de relecture QSO', 'aud.levelHint': 'Si votre voix est plus forte que la station, baissez le niveau micro.',
|
||||||
'aud.autoSend': "Envoyer automatiquement l'enregistrement à la station par e-mail lorsque j'enregistre un QSO",
|
'aud.autoSend': "Envoyer automatiquement l'enregistrement à la station par e-mail lorsque j'enregistre un QSO",
|
||||||
'aud.dvkTitle': 'Messages du manipulateur vocal (F1–F6)', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série',
|
'aud.dvkTitle': 'Messages du manipulateur vocal (F1–F6)', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série',
|
||||||
'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser',
|
'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser',
|
||||||
|
|||||||
@@ -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
|
// 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
|
// settings table (ui.* keys, like the grid columns) so the whole setup is
|
||||||
// identical after a copy.
|
// 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
|
// Keys that must travel with data/ (DB is the portable source of truth; the
|
||||||
// localStorage copy is just a fast, synchronous cache).
|
// localStorage copy is just a fast, synchronous cache).
|
||||||
@@ -28,6 +28,9 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
'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.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
'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.
|
// Cluster filter selections — restored on reopen.
|
||||||
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
@@ -62,5 +65,11 @@ export async function syncPortablePrefs(): Promise<void> {
|
|||||||
// Use it everywhere these keys are written instead of localStorage.setItem.
|
// Use it everywhere these keys are written instead of localStorage.setItem.
|
||||||
export function writeUiPref(key: string, value: string): void {
|
export function writeUiPref(key: string, value: string): void {
|
||||||
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
|
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 {}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import App from './App'
|
|||||||
import { syncPortablePrefs } from './lib/uiPref'
|
import { syncPortablePrefs } from './lib/uiPref'
|
||||||
import { I18nProvider } from './lib/i18n'
|
import { I18nProvider } from './lib/i18n'
|
||||||
import { ThemeProvider, initTheme } from './lib/theme'
|
import { ThemeProvider, initTheme } from './lib/theme'
|
||||||
|
import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary'
|
||||||
|
import { reloadDateFormat } from './lib/dateFormat'
|
||||||
|
|
||||||
const container = document.getElementById('root')
|
const container = document.getElementById('root')
|
||||||
|
|
||||||
@@ -17,13 +19,23 @@ syncPortablePrefs().finally(() => {
|
|||||||
// Stamp the persisted theme onto <html> before render so the correct
|
// Stamp the persisted theme onto <html> before render so the correct
|
||||||
// palette is applied immediately (portable pref hydrated just above).
|
// palette is applied immediately (portable pref hydrated just above).
|
||||||
initTheme()
|
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.
|
||||||
|
installGlobalErrorLogging()
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<App/>
|
<App/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -679,10 +679,10 @@
|
|||||||
disappears on the dark themes. Invert it there — the control itself is worth
|
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
|
keeping (OS calendar, locale date order, keyboard entry), only its icon needs
|
||||||
help. */
|
help. */
|
||||||
[data-theme='dim-slate'] input[type='date']::-webkit-calendar-picker-indicator,
|
Every temporal input carries the same glyph, so the rule names them all:
|
||||||
[data-theme='dark-warm'] input[type='date']::-webkit-calendar-picker-indicator,
|
datetime-local was left out at first and its icon stayed invisible. */
|
||||||
[data-theme='dark-graphite'] input[type='date']::-webkit-calendar-picker-indicator,
|
:is([data-theme='dim-slate'], [data-theme='dark-warm'], [data-theme='dark-graphite'], [data-theme='high-contrast'])
|
||||||
[data-theme='high-contrast'] input[type='date']::-webkit-calendar-picker-indicator {
|
: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.6);
|
filter: invert(1) brightness(1.8);
|
||||||
opacity: 0.75;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.22.0';
|
export const APP_VERSION = '0.22.7';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+29
-1
@@ -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 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 AudioMonitorActive():Promise<boolean>;
|
||||||
|
|
||||||
export function AudioStartMonitor():Promise<void>;
|
export function AudioStartMonitor():Promise<void>;
|
||||||
@@ -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 BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||||
|
|
||||||
|
export function CIVTraceEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function CWDecoderRunning():Promise<boolean>;
|
export function CWDecoderRunning():Promise<boolean>;
|
||||||
|
|
||||||
export function ChatAvailable():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 FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexApplyBandPower(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexBackspaceCW(arg1:number):Promise<void>;
|
export function FlexBackspaceCW(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexMox(arg1:boolean):Promise<void>;
|
export function FlexMox(arg1:boolean):Promise<void>;
|
||||||
@@ -417,6 +423,8 @@ export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
|||||||
|
|
||||||
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
|
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
|
||||||
|
|
||||||
|
export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
|
||||||
|
|
||||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||||
|
|
||||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||||
@@ -589,7 +597,7 @@ export function IcomStopCW():Promise<void>;
|
|||||||
|
|
||||||
export function IcomTune():Promise<void>;
|
export function IcomTune():Promise<void>;
|
||||||
|
|
||||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean,arg5:Record<string, string>):Promise<adif.ImportResult>;
|
||||||
|
|
||||||
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
@@ -597,6 +605,8 @@ export function ImportAwards():Promise<main.AwardImportResult>;
|
|||||||
|
|
||||||
export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
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 LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||||
|
|
||||||
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||||
@@ -637,6 +647,8 @@ export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
|||||||
|
|
||||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||||
|
|
||||||
|
export function LogUIError(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|
||||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
|
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
|
||||||
@@ -745,10 +757,20 @@ export function QSOAudioBegin():Promise<boolean>;
|
|||||||
|
|
||||||
export function QSOAudioCancel():Promise<void>;
|
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 QSOAudioResetClock():Promise<boolean>;
|
||||||
|
|
||||||
export function QSOAudioRestart():Promise<boolean>;
|
export function QSOAudioRestart():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioResume():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioStop():Promise<boolean>;
|
||||||
|
|
||||||
export function QuitApp():Promise<void>;
|
export function QuitApp():Promise<void>;
|
||||||
|
|
||||||
export function RecomputeAllAwardRefs():Promise<number>;
|
export function RecomputeAllAwardRefs():Promise<number>;
|
||||||
@@ -835,6 +857,8 @@ export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>
|
|||||||
|
|
||||||
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):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 SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||||
@@ -891,6 +915,8 @@ export function SetCATFrequency(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function SetCATMode(arg1:string):Promise<void>;
|
export function SetCATMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetCIVTrace(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||||
@@ -1033,6 +1059,8 @@ export function WinkeyerSetSpeed(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function WinkeyerStop():Promise<void>;
|
export function WinkeyerStop():Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerTraceEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||||
|
|
||||||
export function YaesuSendCW(arg1:string):Promise<void>;
|
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);
|
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() {
|
export function AudioMonitorActive() {
|
||||||
return window['go']['main']['App']['AudioMonitorActive']();
|
return window['go']['main']['App']['AudioMonitorActive']();
|
||||||
}
|
}
|
||||||
@@ -130,6 +134,10 @@ export function BulkUpdateQSL(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CIVTraceEnabled() {
|
||||||
|
return window['go']['main']['App']['CIVTraceEnabled']();
|
||||||
|
}
|
||||||
|
|
||||||
export function CWDecoderRunning() {
|
export function CWDecoderRunning() {
|
||||||
return window['go']['main']['App']['CWDecoderRunning']();
|
return window['go']['main']['App']['CWDecoderRunning']();
|
||||||
}
|
}
|
||||||
@@ -378,6 +386,10 @@ export function FlexApplyBandAntenna(arg1) {
|
|||||||
return window['go']['main']['App']['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) {
|
export function FlexBackspaceCW(arg1) {
|
||||||
return window['go']['main']['App']['FlexBackspaceCW'](arg1);
|
return window['go']['main']['App']['FlexBackspaceCW'](arg1);
|
||||||
}
|
}
|
||||||
@@ -782,6 +794,10 @@ export function GetFlexBandAntennas() {
|
|||||||
return window['go']['main']['App']['GetFlexBandAntennas']();
|
return window['go']['main']['App']['GetFlexBandAntennas']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetFlexBandPower() {
|
||||||
|
return window['go']['main']['App']['GetFlexBandPower']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetFlexState() {
|
export function GetFlexState() {
|
||||||
return window['go']['main']['App']['GetFlexState']();
|
return window['go']['main']['App']['GetFlexState']();
|
||||||
}
|
}
|
||||||
@@ -1126,8 +1142,8 @@ export function IcomTune() {
|
|||||||
return window['go']['main']['App']['IcomTune']();
|
return window['go']['main']['App']['IcomTune']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportADIF(arg1, arg2, arg3, arg4) {
|
export function ImportADIF(arg1, arg2, arg3, arg4, arg5) {
|
||||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
|
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4, arg5);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportAwardReferencesText(arg1, arg2) {
|
export function ImportAwardReferencesText(arg1, arg2) {
|
||||||
@@ -1142,6 +1158,10 @@ export function InspectAwardImport() {
|
|||||||
return window['go']['main']['App']['InspectAwardImport']();
|
return window['go']['main']['App']['InspectAwardImport']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IsNewUSCounty(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['IsNewUSCounty'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1) {
|
export function LaunchAutostartProgram(arg1) {
|
||||||
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1222,6 +1242,10 @@ export function LogUDPLoggedADIF(arg1) {
|
|||||||
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LogUIError(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['LogUIError'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function LookupCallsign(arg1) {
|
export function LookupCallsign(arg1) {
|
||||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1438,6 +1462,18 @@ export function QSOAudioCancel() {
|
|||||||
return window['go']['main']['App']['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() {
|
export function QSOAudioResetClock() {
|
||||||
return window['go']['main']['App']['QSOAudioResetClock']();
|
return window['go']['main']['App']['QSOAudioResetClock']();
|
||||||
}
|
}
|
||||||
@@ -1446,6 +1482,14 @@ export function QSOAudioRestart() {
|
|||||||
return window['go']['main']['App']['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() {
|
export function QuitApp() {
|
||||||
return window['go']['main']['App']['QuitApp']();
|
return window['go']['main']['App']['QuitApp']();
|
||||||
}
|
}
|
||||||
@@ -1618,6 +1662,10 @@ export function SaveFlexBandAntennas(arg1) {
|
|||||||
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
|
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveFlexBandPower(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveFlexBandPower'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveListsSettings(arg1) {
|
export function SaveListsSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1730,6 +1778,10 @@ export function SetCATMode(arg1) {
|
|||||||
return window['go']['main']['App']['SetCATMode'](arg1);
|
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetCIVTrace(arg1) {
|
||||||
|
return window['go']['main']['App']['SetCIVTrace'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetCWDecoderPitch(arg1) {
|
export function SetCWDecoderPitch(arg1) {
|
||||||
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2014,6 +2066,10 @@ export function WinkeyerStop() {
|
|||||||
return window['go']['main']['App']['WinkeyerStop']();
|
return window['go']['main']['App']['WinkeyerStop']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function WinkeyerTraceEnabled() {
|
||||||
|
return window['go']['main']['App']['WinkeyerTraceEnabled']();
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkedBefore(arg1, arg2) {
|
export function WorkedBefore(arg1, arg2) {
|
||||||
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ export namespace award {
|
|||||||
leading_str?: string;
|
leading_str?: string;
|
||||||
trailing_str?: string;
|
trailing_str?: string;
|
||||||
dynamic?: boolean;
|
dynamic?: boolean;
|
||||||
|
one_ref_per_qso?: boolean;
|
||||||
or_rules?: OrRule[];
|
or_rules?: OrRule[];
|
||||||
dxcc_filter: number[];
|
dxcc_filter: number[];
|
||||||
valid_bands?: string[];
|
valid_bands?: string[];
|
||||||
@@ -356,6 +357,7 @@ export namespace award {
|
|||||||
this.leading_str = source["leading_str"];
|
this.leading_str = source["leading_str"];
|
||||||
this.trailing_str = source["trailing_str"];
|
this.trailing_str = source["trailing_str"];
|
||||||
this.dynamic = source["dynamic"];
|
this.dynamic = source["dynamic"];
|
||||||
|
this.one_ref_per_qso = source["one_ref_per_qso"];
|
||||||
this.or_rules = this.convertValues(source["or_rules"], OrRule);
|
this.or_rules = this.convertValues(source["or_rules"], OrRule);
|
||||||
this.dxcc_filter = source["dxcc_filter"];
|
this.dxcc_filter = source["dxcc_filter"];
|
||||||
this.valid_bands = source["valid_bands"];
|
this.valid_bands = source["valid_bands"];
|
||||||
@@ -461,6 +463,8 @@ export namespace award {
|
|||||||
ref_count: number;
|
ref_count: number;
|
||||||
steps: Step[];
|
steps: Step[];
|
||||||
manual?: string[];
|
manual?: string[];
|
||||||
|
superseded?: string[];
|
||||||
|
ambiguous?: string[];
|
||||||
result: string[];
|
result: string[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@@ -476,6 +480,8 @@ export namespace award {
|
|||||||
this.ref_count = source["ref_count"];
|
this.ref_count = source["ref_count"];
|
||||||
this.steps = this.convertValues(source["steps"], Step);
|
this.steps = this.convertValues(source["steps"], Step);
|
||||||
this.manual = source["manual"];
|
this.manual = source["manual"];
|
||||||
|
this.superseded = source["superseded"];
|
||||||
|
this.ambiguous = source["ambiguous"];
|
||||||
this.result = source["result"];
|
this.result = source["result"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +512,8 @@ export namespace award {
|
|||||||
worked: boolean;
|
worked: boolean;
|
||||||
confirmed: boolean;
|
confirmed: boolean;
|
||||||
validated: boolean;
|
validated: boolean;
|
||||||
|
modes: string[];
|
||||||
|
confirmed_modes: string[];
|
||||||
bands: string[];
|
bands: string[];
|
||||||
confirmed_bands: string[];
|
confirmed_bands: string[];
|
||||||
validated_bands: string[];
|
validated_bands: string[];
|
||||||
@@ -523,6 +531,8 @@ export namespace award {
|
|||||||
this.worked = source["worked"];
|
this.worked = source["worked"];
|
||||||
this.confirmed = source["confirmed"];
|
this.confirmed = source["confirmed"];
|
||||||
this.validated = source["validated"];
|
this.validated = source["validated"];
|
||||||
|
this.modes = source["modes"];
|
||||||
|
this.confirmed_modes = source["confirmed_modes"];
|
||||||
this.bands = source["bands"];
|
this.bands = source["bands"];
|
||||||
this.confirmed_bands = source["confirmed_bands"];
|
this.confirmed_bands = source["confirmed_bands"];
|
||||||
this.validated_bands = source["validated_bands"];
|
this.validated_bands = source["validated_bands"];
|
||||||
@@ -1297,6 +1307,7 @@ export namespace extsvc {
|
|||||||
hrdlog: ServiceConfig;
|
hrdlog: ServiceConfig;
|
||||||
eqsl: ServiceConfig;
|
eqsl: ServiceConfig;
|
||||||
cloudlog: ServiceConfig;
|
cloudlog: ServiceConfig;
|
||||||
|
delete_remote: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ExternalServices(source);
|
return new ExternalServices(source);
|
||||||
@@ -1310,6 +1321,7 @@ export namespace extsvc {
|
|||||||
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
|
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
|
||||||
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
||||||
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
|
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
|
||||||
|
this.delete_remote = source["delete_remote"];
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
@@ -1602,6 +1614,7 @@ export namespace main {
|
|||||||
from_gain: number;
|
from_gain: number;
|
||||||
mic_gain: number;
|
mic_gain: number;
|
||||||
tx_gain: number;
|
tx_gain: number;
|
||||||
|
qso_play_gain: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new AudioSettings(source);
|
return new AudioSettings(source);
|
||||||
@@ -1622,6 +1635,7 @@ export namespace main {
|
|||||||
this.from_gain = source["from_gain"];
|
this.from_gain = source["from_gain"];
|
||||||
this.mic_gain = source["mic_gain"];
|
this.mic_gain = source["mic_gain"];
|
||||||
this.tx_gain = source["tx_gain"];
|
this.tx_gain = source["tx_gain"];
|
||||||
|
this.qso_play_gain = source["qso_play_gain"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class AutostartLaunchResult {
|
export class AutostartLaunchResult {
|
||||||
@@ -1904,8 +1918,12 @@ export namespace main {
|
|||||||
xiegu_port: string;
|
xiegu_port: string;
|
||||||
xiegu_baud: number;
|
xiegu_baud: number;
|
||||||
xiegu_addr: number;
|
xiegu_addr: number;
|
||||||
|
xiegu_ptt_line: string;
|
||||||
yaesu_port: string;
|
yaesu_port: string;
|
||||||
yaesu_baud: number;
|
yaesu_baud: number;
|
||||||
|
kenwood_host: string;
|
||||||
|
kenwood_port: string;
|
||||||
|
kenwood_baud: number;
|
||||||
icom_port: string;
|
icom_port: string;
|
||||||
icom_baud: number;
|
icom_baud: number;
|
||||||
icom_addr: number;
|
icom_addr: number;
|
||||||
@@ -1940,8 +1958,12 @@ export namespace main {
|
|||||||
this.xiegu_port = source["xiegu_port"];
|
this.xiegu_port = source["xiegu_port"];
|
||||||
this.xiegu_baud = source["xiegu_baud"];
|
this.xiegu_baud = source["xiegu_baud"];
|
||||||
this.xiegu_addr = source["xiegu_addr"];
|
this.xiegu_addr = source["xiegu_addr"];
|
||||||
|
this.xiegu_ptt_line = source["xiegu_ptt_line"];
|
||||||
this.yaesu_port = source["yaesu_port"];
|
this.yaesu_port = source["yaesu_port"];
|
||||||
this.yaesu_baud = source["yaesu_baud"];
|
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.icom_port = source["icom_port"];
|
this.icom_port = source["icom_port"];
|
||||||
this.icom_baud = source["icom_baud"];
|
this.icom_baud = source["icom_baud"];
|
||||||
this.icom_addr = source["icom_addr"];
|
this.icom_addr = source["icom_addr"];
|
||||||
@@ -2686,6 +2708,7 @@ export namespace main {
|
|||||||
ref: string;
|
ref: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
pickable: boolean;
|
pickable: boolean;
|
||||||
|
ambiguous?: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new QSOAwardRef(source);
|
return new QSOAwardRef(source);
|
||||||
@@ -2697,6 +2720,7 @@ export namespace main {
|
|||||||
this.ref = source["ref"];
|
this.ref = source["ref"];
|
||||||
this.name = source["name"];
|
this.name = source["name"];
|
||||||
this.pickable = source["pickable"];
|
this.pickable = source["pickable"];
|
||||||
|
this.ambiguous = source["ambiguous"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class QSORate {
|
export class QSORate {
|
||||||
@@ -2799,6 +2823,7 @@ export namespace main {
|
|||||||
rotator_num: number;
|
rotator_num: number;
|
||||||
transport: string;
|
transport: string;
|
||||||
com_port: string;
|
com_port: string;
|
||||||
|
baud: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new RotatorSettings(source);
|
return new RotatorSettings(source);
|
||||||
@@ -2814,6 +2839,7 @@ export namespace main {
|
|||||||
this.rotator_num = source["rotator_num"];
|
this.rotator_num = source["rotator_num"];
|
||||||
this.transport = source["transport"];
|
this.transport = source["transport"];
|
||||||
this.com_port = source["com_port"];
|
this.com_port = source["com_port"];
|
||||||
|
this.baud = source["baud"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class ScpStatus {
|
export class ScpStatus {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The confirmation statuses an import fills in, and the ones it must not touch.
|
||||||
|
//
|
||||||
|
// An operator asked for this feature believing it missing — it has been there
|
||||||
|
// since v0.14, but the option's description never mentioned it, so nobody could
|
||||||
|
// know. The behaviour is pinned here so a future edit cannot quietly drop what
|
||||||
|
// the description now promises.
|
||||||
|
//
|
||||||
|
// The rule is fill-if-empty: a WSJT-X log carries almost no QSL fields, while a
|
||||||
|
// log exported from another program carries real ones that must survive.
|
||||||
|
func TestQSLDefaultsFillOnlyEmptyFields(t *testing.T) {
|
||||||
|
defaults := QSLDefaults{
|
||||||
|
QSLSent: "N", QSLRcvd: "N",
|
||||||
|
LOTWSent: "N", LOTWRcvd: "N",
|
||||||
|
EQSLSent: "N", EQSLRcvd: "N",
|
||||||
|
ClublogStatus: "N", HRDLogStatus: "N",
|
||||||
|
QRZComStatus: "N", QRZComCfm: "N",
|
||||||
|
}
|
||||||
|
|
||||||
|
// A record as WSJT-X writes it: no confirmation fields at all.
|
||||||
|
bare := &qso.QSO{Callsign: "F4XYZ"}
|
||||||
|
applyQSLDefaultsTo(bare, defaults)
|
||||||
|
for name, got := range map[string]string{
|
||||||
|
"qsl_sent": bare.QSLSent, "qsl_rcvd": bare.QSLRcvd,
|
||||||
|
"lotw_sent": bare.LOTWSent, "lotw_rcvd": bare.LOTWRcvd,
|
||||||
|
"eqsl_sent": bare.EQSLSent, "eqsl_rcvd": bare.EQSLRcvd,
|
||||||
|
"clublog": bare.ClublogUploadStatus, "hrdlog": bare.HRDLogUploadStatus,
|
||||||
|
"qrz_sent": bare.QRZComUploadStatus, "qrz_rcvd": bare.QRZComDownloadStatus,
|
||||||
|
} {
|
||||||
|
if got != "N" {
|
||||||
|
t.Errorf("%s = %q on an empty record, want the default \"N\"", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A record that already carries confirmations — from a real logbook export.
|
||||||
|
// Overwriting these would erase QSLs the operator has actually received.
|
||||||
|
kept := &qso.QSO{
|
||||||
|
Callsign: "F4XYZ",
|
||||||
|
QSLRcvd: "Y", LOTWRcvd: "Y", EQSLSent: "Y",
|
||||||
|
ClublogUploadStatus: "Y", QRZComDownloadStatus: "Y",
|
||||||
|
}
|
||||||
|
applyQSLDefaultsTo(kept, defaults)
|
||||||
|
for name, got := range map[string]string{
|
||||||
|
"qsl_rcvd": kept.QSLRcvd, "lotw_rcvd": kept.LOTWRcvd, "eqsl_sent": kept.EQSLSent,
|
||||||
|
"clublog": kept.ClublogUploadStatus, "qrz_rcvd": kept.QRZComDownloadStatus,
|
||||||
|
} {
|
||||||
|
if got != "Y" {
|
||||||
|
t.Errorf("%s = %q — an existing confirmation was overwritten", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// …while its empty ones still get the default.
|
||||||
|
if kept.QSLSent != "N" || kept.LOTWSent != "N" {
|
||||||
|
t.Errorf("empty fields on a partly-filled record were not defaulted: qsl_sent=%q lotw_sent=%q",
|
||||||
|
kept.QSLSent, kept.LOTWSent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,21 @@ type Importer struct {
|
|||||||
// Used to recompute country / zones from cty.dat so a bad COUNTRY in the
|
// Used to recompute country / zones from cty.dat so a bad COUNTRY in the
|
||||||
// source file (common with contest loggers) is corrected on the way in.
|
// source file (common with contest loggers) is corrected on the way in.
|
||||||
Enrich func(*qso.QSO)
|
Enrich func(*qso.QSO)
|
||||||
|
// FieldMap moves ADIF fields around before a record is converted, keyed
|
||||||
|
// lowercase source → lowercase destination.
|
||||||
|
//
|
||||||
|
// Contest software puts things where the operator, not the standard, expects
|
||||||
|
// them. The RSGB IOTA contest is the case that prompted this: N1MM and
|
||||||
|
// friends export the island reference in STATE, because that is the column
|
||||||
|
// their contest module uses for the received exchange. Imported verbatim,
|
||||||
|
// every QSO gets a US-state field holding "EU005" and the IOTA award sees
|
||||||
|
// nothing at all.
|
||||||
|
//
|
||||||
|
// Applied to the RAW record, so it works for promoted columns and Extras
|
||||||
|
// alike, and so the destination is parsed exactly as if the file had carried
|
||||||
|
// it there in the first place. A destination that already has a value is
|
||||||
|
// never overwritten — the file's own IOTA tag outranks a guess about STATE.
|
||||||
|
FieldMap map[string]string
|
||||||
// OnProgress, when set, is called periodically with (processed, total)
|
// OnProgress, when set, is called periodically with (processed, total)
|
||||||
// record counts so the UI can show a progress bar. total is an estimate
|
// record counts so the UI can show a progress bar. total is an estimate
|
||||||
// from counting <EOR> tags up front.
|
// from counting <EOR> tags up front.
|
||||||
@@ -161,6 +176,7 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
|
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
|
||||||
res.Total++
|
res.Total++
|
||||||
reportProgress(false)
|
reportProgress(false)
|
||||||
|
applyFieldMap(rec, im.FieldMap)
|
||||||
q, ok := recordToQSO(rec)
|
q, ok := recordToQSO(rec)
|
||||||
if !ok {
|
if !ok {
|
||||||
res.Skipped++
|
res.Skipped++
|
||||||
@@ -303,6 +319,49 @@ func stringSet(items ...string) map[string]struct{} {
|
|||||||
// RecordToQSO is the exported alias used by the UDP auto-log path so it
|
// RecordToQSO is the exported alias used by the UDP auto-log path so it
|
||||||
// can convert a freshly received ADIF record into a QSO and then enrich
|
// can convert a freshly received ADIF record into a QSO and then enrich
|
||||||
// it with lookup + operating data before inserting.
|
// it with lookup + operating data before inserting.
|
||||||
|
// applyFieldMap moves values between tags of a record, in place.
|
||||||
|
//
|
||||||
|
// The source is CLEARED after the move: a reference that belongs in IOTA should
|
||||||
|
// not also be left behind in STATE, where it would show up as the contacted
|
||||||
|
// station's US state in the grid and in every export.
|
||||||
|
func applyFieldMap(rec Record, m map[string]string) {
|
||||||
|
if len(m) == 0 || rec == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Read every source first. Applied one at a time, a swap (a→b, b→a) would
|
||||||
|
// read back what the previous move had just written.
|
||||||
|
orig := make(map[string]string, len(rec))
|
||||||
|
for k, v := range rec {
|
||||||
|
orig[k] = v
|
||||||
|
}
|
||||||
|
vals := make(map[string]string, len(m))
|
||||||
|
for from := range m {
|
||||||
|
if v := strings.TrimSpace(rec[from]); v != "" {
|
||||||
|
vals[from] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for from := range m {
|
||||||
|
delete(rec, from)
|
||||||
|
}
|
||||||
|
for from, to := range m {
|
||||||
|
v, ok := vals[from]
|
||||||
|
if !ok || from == to || to == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Whether the destination was already filled is judged against the record
|
||||||
|
// as it ARRIVED, not as we are rewriting it — otherwise the outcome would
|
||||||
|
// depend on Go's map iteration order, which is deliberately random.
|
||||||
|
//
|
||||||
|
// A destination that is itself a mapping source is an explicit swap, and
|
||||||
|
// overwriting it is the whole point; anything else keeps what the file
|
||||||
|
// carried, because the file's own tag outranks a guess about where a
|
||||||
|
// reference might have been put.
|
||||||
|
if _, swap := m[to]; swap || strings.TrimSpace(orig[to]) == "" {
|
||||||
|
rec[to] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func RecordToQSO(rec Record) (qso.QSO, bool) { return recordToQSO(rec) }
|
func RecordToQSO(rec Record) (qso.QSO, bool) { return recordToQSO(rec) }
|
||||||
|
|
||||||
// recordToQSO maps an ADIF record onto a QSO. Returns false if required
|
// recordToQSO maps an ADIF record onto a QSO. Returns false if required
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package adif_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/adif"
|
||||||
|
"hamlog/internal/db"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// End to end: an operator's own record, through a real SQLite logbook and back.
|
||||||
|
//
|
||||||
|
// Written because CONTEST_ID was reported as "not imported". It is imported, and
|
||||||
|
// this pins the whole path — parse, remap, insert, read back — rather than the
|
||||||
|
// conversion step alone, which was already green while the report stood. A field
|
||||||
|
// can be lost at five places between the file and the grid; only the round trip
|
||||||
|
// says which.
|
||||||
|
func TestContestIDSurvivesImport(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
conn, err := db.Open(filepath.Join(dir, "logbook.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
const file = "<CALL:6>2E0CVN <QSO_DATE:8>20260725 <TIME_ON:6>120039 <STATE:5>EU005 <BAND:3>20M <FREQ:6>14.014 <MODE:2>CW <CONTEST_ID:9>RSGB-IOTA <RST_RCVD:3>599 <RST_SENT:3>599 <SRX_STRING:8>001EU005 <EOR>\n"
|
||||||
|
path := filepath.Join(dir, "in.adi")
|
||||||
|
if err := os.WriteFile(path, []byte(file), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := qso.NewRepo(conn)
|
||||||
|
im := &adif.Importer{Repo: repo, FieldMap: map[string]string{"state": "iota"}}
|
||||||
|
res, err := im.ImportFile(context.Background(), path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("result: %+v", res)
|
||||||
|
|
||||||
|
rows, err := conn.Query("SELECT callsign, contest_id, iota, state FROM qso")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
n := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var call, contest, iota, state any
|
||||||
|
if err := rows.Scan(&call, &contest, &iota, &state); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
t.Logf("stored: call=%v contest_id=%v iota=%v state=%v", call, contest, iota, state)
|
||||||
|
if s := strings.TrimSpace(toS(contest)); s != "RSGB-IOTA" {
|
||||||
|
t.Errorf("contest_id in the DATABASE = %q, want RSGB-IOTA", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
t.Fatal("nothing was stored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toS(v any) string {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case string:
|
||||||
|
return x
|
||||||
|
case []byte:
|
||||||
|
return string(x)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package adif
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Field mapping, from the RSGB IOTA contest case.
|
||||||
|
//
|
||||||
|
// N1MM and similar export the island reference in STATE, because that is the
|
||||||
|
// column their contest module uses for the received exchange. Imported
|
||||||
|
// verbatim, every QSO gets a US-state field reading "EU005" and the IOTA award
|
||||||
|
// sees nothing — the reference is in the log but not where anything looks.
|
||||||
|
func TestApplyFieldMap(t *testing.T) {
|
||||||
|
// The real record, from an operator's TM2Q log.
|
||||||
|
rec := Record{
|
||||||
|
"call": "2E0CVN", "band": "20m", "mode": "CW",
|
||||||
|
"state": "EU005", "srx_string": "001EU005",
|
||||||
|
}
|
||||||
|
applyFieldMap(rec, map[string]string{"state": "iota"})
|
||||||
|
if rec["iota"] != "EU005" {
|
||||||
|
t.Errorf("iota = %q, want EU005", rec["iota"])
|
||||||
|
}
|
||||||
|
// Cleared, not copied: a reference left behind in STATE would show up as the
|
||||||
|
// contacted station's US state in the grid and in every export.
|
||||||
|
if _, ok := rec["state"]; ok {
|
||||||
|
t.Errorf("state survived the move as %q", rec["state"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A destination the file already filled is never overwritten — the file's own
|
||||||
|
// tag outranks a guess about where the reference might be.
|
||||||
|
rec2 := Record{"call": "UT0RM", "state": "EU005", "iota": "EU030"}
|
||||||
|
applyFieldMap(rec2, map[string]string{"state": "iota"})
|
||||||
|
if rec2["iota"] != "EU030" {
|
||||||
|
t.Errorf("iota = %q — the file's own value must win", rec2["iota"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty source moves nothing (the second QSO of that log has no STATE).
|
||||||
|
rec3 := Record{"call": "UT0RM", "state": " "}
|
||||||
|
applyFieldMap(rec3, map[string]string{"state": "iota"})
|
||||||
|
if v, ok := rec3["iota"]; ok {
|
||||||
|
t.Errorf("an empty source created iota=%q", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A swap reads both sources before writing either.
|
||||||
|
rec4 := Record{"state": "EU005", "iota": "NA001"}
|
||||||
|
applyFieldMap(rec4, map[string]string{"state": "iota", "iota": "state"})
|
||||||
|
if rec4["iota"] != "EU005" || rec4["state"] != "NA001" {
|
||||||
|
t.Errorf("swap gave iota=%q state=%q, want EU005 / NA001", rec4["iota"], rec4["state"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// No mapping, no change.
|
||||||
|
rec5 := Record{"call": "F4BPO", "state": "EU005"}
|
||||||
|
applyFieldMap(rec5, nil)
|
||||||
|
if rec5["state"] != "EU005" {
|
||||||
|
t.Error("a nil mapping must leave the record alone")
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-7
@@ -22,9 +22,11 @@ import (
|
|||||||
// Record is a single ADIF record. Keys are lowercased field names.
|
// Record is a single ADIF record. Keys are lowercased field names.
|
||||||
type Record map[string]string
|
type Record map[string]string
|
||||||
|
|
||||||
// Parse reads an ADI stream and invokes fn for each record (after <EOH>).
|
// Parse reads an ADI stream and invokes fn for each record.
|
||||||
// Returning a non-nil error from fn stops parsing and is propagated.
|
// Returning a non-nil error from fn stops parsing and is propagated.
|
||||||
// The header (text before <EOH>) is silently discarded.
|
//
|
||||||
|
// The header (text before <EOH>) is silently discarded. A file with NO header is
|
||||||
|
// read as records from the first tag — hand-assembled files often have none.
|
||||||
func Parse(r io.Reader, fn func(Record) error) error {
|
func Parse(r io.Reader, fn func(Record) error) error {
|
||||||
return parseWith(r, nil, fn)
|
return parseWith(r, nil, fn)
|
||||||
}
|
}
|
||||||
@@ -44,7 +46,6 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
|||||||
br := bufio.NewReaderSize(r, 64*1024)
|
br := bufio.NewReaderSize(r, 64*1024)
|
||||||
|
|
||||||
rec := Record{}
|
rec := Record{}
|
||||||
headerDone := false
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
// Seek next '<'. Bytes before it are either header text or
|
// Seek next '<'. Bytes before it are either header text or
|
||||||
@@ -65,11 +66,19 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
|||||||
name, length := parseSpec(spec)
|
name, length := parseSpec(spec)
|
||||||
switch name {
|
switch name {
|
||||||
case "eoh":
|
case "eoh":
|
||||||
headerDone = true
|
rec = Record{} // everything collected so far was the header
|
||||||
rec = Record{}
|
|
||||||
continue
|
continue
|
||||||
case "eor":
|
case "eor":
|
||||||
if headerDone && len(rec) > 0 {
|
// An <eor> proves we are in the record section, whether or not an <eoh>
|
||||||
|
// came first. Files pasted together by hand — a few lines lifted out of
|
||||||
|
// wsjtx_log.adi into a new file — have no header at all, and requiring
|
||||||
|
// one made every record invisible: the import reported 0 imported,
|
||||||
|
// 0 ignored, 0 total, which reads as "the file is empty" rather than
|
||||||
|
// "there was no header".
|
||||||
|
//
|
||||||
|
// A real header cannot contain <eor>, so accepting it costs nothing for
|
||||||
|
// well-formed files: theirs ends at <eoh> long before any record.
|
||||||
|
if len(rec) > 0 {
|
||||||
if err := fn(rec); err != nil {
|
if err := fn(rec); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -95,7 +104,14 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
|||||||
if decodeValue == nil && !utf8.Valid(val) {
|
if decodeValue == nil && !utf8.Valid(val) {
|
||||||
val = extendToRunes(br, val, length)
|
val = extendToRunes(br, val, length)
|
||||||
}
|
}
|
||||||
if headerDone && name != "" {
|
// Fields are collected ALWAYS, and thrown away at <eoh>.
|
||||||
|
//
|
||||||
|
// Collecting them only after the header meant a headerless file lost its
|
||||||
|
// FIRST record whole: its fields were discarded as header text, and the
|
||||||
|
// <eor> that followed found nothing to emit. Discarding at <eoh> instead
|
||||||
|
// keeps real headers out of the data just as well — that tag is exactly
|
||||||
|
// what says "everything before this was the header".
|
||||||
|
if name != "" {
|
||||||
if decodeValue != nil {
|
if decodeValue != nil {
|
||||||
rec[name] = decodeValue(val)
|
rec[name] = decodeValue(val)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -51,19 +51,28 @@ func TestParseValueWithAngleBracket(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseNoHeader(t *testing.T) {
|
func TestParseNoHeader(t *testing.T) {
|
||||||
// Some loggers omit the header entirely — records before <EOH> are
|
// A file without a header is IMPORTED, not discarded.
|
||||||
// discarded by design. Verify nothing is emitted in that case.
|
//
|
||||||
|
// This test asserted the opposite until 2026-07-29, when an operator pasted a
|
||||||
|
// dozen lines out of wsjtx_log.adi into a new .adi and got "0 imported,
|
||||||
|
// 0 ignored, 0 total". Discarding records before <EOH> was a defensible
|
||||||
|
// reading of the spec, but the file it rejects is one people really make, and
|
||||||
|
// the rejection is silent: the count says the file was empty, not that a tag
|
||||||
|
// was missing. An <eor> is proof enough that a record ended.
|
||||||
src := `<CALL:5>F4XYZ<EOR>`
|
src := `<CALL:5>F4XYZ<EOR>`
|
||||||
var got int
|
var got []Record
|
||||||
err := Parse(strings.NewReader(src), func(r Record) error {
|
err := Parse(strings.NewReader(src), func(r Record) error {
|
||||||
got++
|
got = append(got, r)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("parse: %v", err)
|
t.Fatalf("parse: %v", err)
|
||||||
}
|
}
|
||||||
if got != 0 {
|
if len(got) != 1 {
|
||||||
t.Errorf("expected 0 records without <EOH>, got %d", got)
|
t.Fatalf("expected the record to be imported without <EOH>, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0]["call"] != "F4XYZ" {
|
||||||
|
t.Errorf("call = %q, want F4XYZ", got[0]["call"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,3 +91,57 @@ func TestParseTypedField(t *testing.T) {
|
|||||||
t.Errorf("freq mismatch: %q", got["freq"])
|
t.Errorf("freq mismatch: %q", got["freq"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A file with NO header must still import.
|
||||||
|
//
|
||||||
|
// Reported from the field: an operator lifted a dozen lines out of wsjtx_log.adi
|
||||||
|
// into a new .adi and got "0 imported, 0 ignored, 0 total" — which reads as an
|
||||||
|
// empty file rather than as a missing header. Records were only emitted after
|
||||||
|
// <EOH>, so a hand-assembled file was invisible in its entirety.
|
||||||
|
func TestParseHeaderlessFile(t *testing.T) {
|
||||||
|
// The operator's exact shape: records straight from a WSJT-X log, no header.
|
||||||
|
in := "<call:5>PY2VE <gridsquare:4>GG67 <mode:3>FT8 <rst_sent:3>-18 <rst_rcvd:3>-15 " +
|
||||||
|
"<qso_date:8>20260728 <time_on:6>194200 <band:3>15m <eor>\n" +
|
||||||
|
"<call:5>W8OBX <gridsquare:4>FM25 <mode:3>FT8 <rst_sent:3>-19 <rst_rcvd:3>-12 " +
|
||||||
|
"<qso_date:8>20260728 <time_on:6>194500 <band:3>15m <eor>\n"
|
||||||
|
|
||||||
|
var got []Record
|
||||||
|
if err := Parse(strings.NewReader(in), func(r Record) error {
|
||||||
|
got = append(got, r)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("parsed %d records from a headerless file, want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0]["call"] != "PY2VE" || got[1]["call"] != "W8OBX" {
|
||||||
|
t.Errorf("callsigns = %q, %q — want PY2VE, W8OBX", got[0]["call"], got[1]["call"])
|
||||||
|
}
|
||||||
|
if got[0]["gridsquare"] != "GG67" {
|
||||||
|
t.Errorf("gridsquare = %q, want GG67", got[0]["gridsquare"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a normal file must be unaffected: its header is still discarded, so header
|
||||||
|
// fields never leak into the first record.
|
||||||
|
func TestParseHeaderStillDiscarded(t *testing.T) {
|
||||||
|
in := "Generated by SomeLogger\n<adif_ver:5>3.1.4 <programid:6>OpsLog <eoh>\n" +
|
||||||
|
"<call:4>F4AA <mode:2>CW <eor>\n"
|
||||||
|
var got []Record
|
||||||
|
if err := Parse(strings.NewReader(in), func(r Record) error {
|
||||||
|
got = append(got, r)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("parsed %d records, want 1", len(got))
|
||||||
|
}
|
||||||
|
if _, leaked := got[0]["programid"]; leaked {
|
||||||
|
t.Error("a header field leaked into the record")
|
||||||
|
}
|
||||||
|
if got[0]["call"] != "F4AA" {
|
||||||
|
t.Errorf("call = %q, want F4AA", got[0]["call"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,3 +101,30 @@ func endpointName(dev *wca.IMMDevice, fallback string) string {
|
|||||||
}
|
}
|
||||||
return fallback
|
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
|
// recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the
|
||||||
// stop channel is closed.
|
// stop channel is closed.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package audio
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager owns the DVK record/playback lifecycle: at most one recording and
|
// Manager owns the DVK record/playback lifecycle: at most one recording and
|
||||||
@@ -14,7 +15,18 @@ type Manager struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
recStop chan struct{}
|
recStop chan struct{}
|
||||||
recDone chan recResult
|
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{}
|
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)
|
monStop chan struct{} // RX monitor passthrough (capture → render)
|
||||||
monRing *pcmRing // live audio hand-off, also fed by the network stream
|
monRing *pcmRing // live audio hand-off, also fed by the network stream
|
||||||
txStop chan struct{} // TX audio passthrough (mic → rig)
|
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)
|
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()
|
m.StopPlayback()
|
||||||
stop := make(chan struct{})
|
stop := make(chan struct{})
|
||||||
|
done := make(chan struct{})
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.playStop = stop
|
m.playStop = stop
|
||||||
|
m.playDone = done
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
go func() {
|
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()
|
m.mu.Lock()
|
||||||
if m.playStop == stop {
|
if m.playStop == stop {
|
||||||
m.playStop = nil
|
m.playStop = nil
|
||||||
}
|
}
|
||||||
|
if m.playDone == done {
|
||||||
|
m.playDone = nil
|
||||||
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
close(done) // the device is free from here
|
||||||
m.notify()
|
m.notify()
|
||||||
}()
|
}()
|
||||||
m.notify()
|
m.notify()
|
||||||
@@ -147,12 +179,24 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error {
|
|||||||
func (m *Manager) StopPlayback() {
|
func (m *Manager) StopPlayback() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
stop := m.playStop
|
stop := m.playStop
|
||||||
|
done := m.playDone
|
||||||
m.playStop = nil
|
m.playStop = nil
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
if stop != nil {
|
if stop == nil {
|
||||||
close(stop)
|
return
|
||||||
m.notify()
|
|
||||||
}
|
}
|
||||||
|
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) --------------------
|
// ---- 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()
|
m.mu.Unlock()
|
||||||
|
|
||||||
if capture {
|
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() {
|
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.
|
// 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
|
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.
|
// StopMonitor stops the RX monitor passthrough.
|
||||||
func (m *Manager) StopMonitor() {
|
func (m *Manager) StopMonitor() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
|||||||
+70
-7
@@ -3,6 +3,7 @@
|
|||||||
package audio
|
package audio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/braheezy/shine-mp3/pkg/mp3"
|
"github.com/braheezy/shine-mp3/pkg/mp3"
|
||||||
@@ -50,19 +51,81 @@ func writeMP3(path string, pcm []byte) error {
|
|||||||
return enc.Write(f, stereo)
|
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 {
|
func upsample2(in []int16) []int16 {
|
||||||
if len(in) == 0 {
|
if len(in) == 0 {
|
||||||
return in
|
return in
|
||||||
}
|
}
|
||||||
out := make([]int16, len(in)*2)
|
out := make([]int16, len(in)*2)
|
||||||
for i := range in {
|
half := len(upsampleFIR) / 2
|
||||||
out[2*i] = in[i]
|
for i := range out {
|
||||||
if i+1 < len(in) {
|
var acc float64
|
||||||
out[2*i+1] = int16((int32(in[i]) + int32(in[i+1])) / 2)
|
// The zero-stuffed signal is non-zero only at even indices, so only
|
||||||
} else {
|
// every other tap contributes — the loop skips the zeros rather than
|
||||||
out[2*i+1] = in[i]
|
// 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
|
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
|
||||||
|
}
|
||||||
+244
-13
@@ -15,6 +15,14 @@ import (
|
|||||||
// Defaults to a no-op so the package is usable without wiring.
|
// Defaults to a no-op so the package is usable without wiring.
|
||||||
var LogSink = func(string, ...any) {}
|
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
|
// 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
|
// 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
|
// catch a hard Windows access violation from the WASAPI layer — those are fatal
|
||||||
@@ -45,6 +53,16 @@ type Recorder struct {
|
|||||||
srcMu sync.Mutex
|
srcMu sync.Mutex
|
||||||
bufA []int16 // From Radio
|
bufA []int16 // From Radio
|
||||||
bufB []int16 // mic
|
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
|
twoSrc bool
|
||||||
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
|
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
|
||||||
gainB float64 // mic gain
|
gainB float64 // mic gain
|
||||||
@@ -52,6 +70,11 @@ type Recorder struct {
|
|||||||
// Mixed output state (guarded by mu).
|
// Mixed output state (guarded by mu).
|
||||||
ring []int16 // last prerollSamples of mixed audio
|
ring []int16 // last prerollSamples of mixed audio
|
||||||
active bool
|
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)
|
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 {
|
if prerollSec < 0 {
|
||||||
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.prerollSamples = prerollSec * sampleRate
|
||||||
r.twoSrc = micDev != "" && micDev != fromDev
|
r.twoSrc = micDev != "" && micDev != fromDev
|
||||||
r.stopCh = make(chan struct{})
|
r.stopCh = make(chan struct{})
|
||||||
r.running = true
|
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
|
stop := r.stopCh
|
||||||
twoSrc := r.twoSrc
|
twoSrc := r.twoSrc
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
@@ -124,27 +173,79 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
|||||||
go func() {
|
go func() {
|
||||||
defer r.wg.Done()
|
defer r.wg.Done()
|
||||||
defer recoverGoroutine("recorder capture (radio)")
|
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)
|
s := bytesToInt16(chunk)
|
||||||
r.srcMu.Lock()
|
r.srcMu.Lock()
|
||||||
r.bufA = append(r.bufA, s...)
|
r.bufA = append(r.bufA, s...)
|
||||||
|
r.lastA = time.Now()
|
||||||
r.srcMu.Unlock()
|
r.srcMu.Unlock()
|
||||||
})
|
}); err != nil {
|
||||||
|
LogSink("recorder: capture from %q failed: %v", DeviceName(fromDev), err)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
if twoSrc {
|
if twoSrc {
|
||||||
r.wg.Add(1)
|
r.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer r.wg.Done()
|
defer r.wg.Done()
|
||||||
defer recoverGoroutine("recorder capture (mic)")
|
defer recoverGoroutine("recorder capture (mic)")
|
||||||
_ = captureStream(micDev, stop, func(chunk []byte) {
|
if err := captureStream(micDev, stop, func(chunk []byte) {
|
||||||
s := bytesToInt16(chunk)
|
s := bytesToInt16(chunk)
|
||||||
r.srcMu.Lock()
|
r.srcMu.Lock()
|
||||||
r.bufB = append(r.bufB, s...)
|
r.bufB = append(r.bufB, s...)
|
||||||
|
r.lastB = time.Now()
|
||||||
r.srcMu.Unlock()
|
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.
|
// Mixer goroutine.
|
||||||
r.wg.Add(1)
|
r.wg.Add(1)
|
||||||
go func() {
|
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
|
// mixTick drains the source queues, mixes what's available, and appends to the
|
||||||
// ring + active accumulation.
|
// 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() {
|
func (r *Recorder) mixTick() {
|
||||||
r.srcMu.Lock()
|
r.srcMu.Lock()
|
||||||
var mixed []int16
|
var mixed []int16
|
||||||
if r.twoSrc {
|
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)
|
n := len(r.bufA)
|
||||||
if len(r.bufB) < n {
|
if len(r.bufB) < n {
|
||||||
n = len(r.bufB)
|
n = len(r.bufB)
|
||||||
}
|
}
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
|
// Both alive again after one was written off.
|
||||||
|
r.deadA, r.deadB = false, false
|
||||||
mixed = make([]int16, n)
|
mixed = make([]int16, n)
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB))
|
mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB))
|
||||||
@@ -182,13 +347,21 @@ func (r *Recorder) mixTick() {
|
|||||||
r.bufA = append(r.bufA[:0], r.bufA[n:]...)
|
r.bufA = append(r.bufA[:0], r.bufA[n:]...)
|
||||||
r.bufB = append(r.bufB[:0], r.bufB[n:]...)
|
r.bufB = append(r.bufB[:0], r.bufB[n:]...)
|
||||||
}
|
}
|
||||||
// Drift guard: if the clocks diverge, drop the excess so the two
|
// Drift guard: two sound cards run on their own clocks, so drop the
|
||||||
// sources stay roughly aligned (≤1 s skew).
|
// 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 {
|
if d := len(r.bufA) - len(r.bufB); d > sampleRate {
|
||||||
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
|
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
|
||||||
} else if d < -sampleRate {
|
} else if d < -sampleRate {
|
||||||
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
|
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if len(r.bufA) > 0 {
|
} else if len(r.bufA) > 0 {
|
||||||
mixed = make([]int16, len(r.bufA))
|
mixed = make([]int16, len(r.bufA))
|
||||||
for i, s := range r.bufA {
|
for i, s := range r.bufA {
|
||||||
@@ -198,6 +371,12 @@ func (r *Recorder) mixTick() {
|
|||||||
}
|
}
|
||||||
r.srcMu.Unlock()
|
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 {
|
if len(mixed) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -206,7 +385,7 @@ func (r *Recorder) mixTick() {
|
|||||||
if len(r.ring) > r.prerollSamples {
|
if len(r.ring) > r.prerollSamples {
|
||||||
r.ring = append(r.ring[:0], r.ring[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.acc = append(r.acc, mixed...)
|
||||||
}
|
}
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
@@ -221,7 +400,7 @@ func (r *Recorder) BeginQSO() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.acc = append([]int16(nil), r.ring...)
|
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 —
|
// RestartQSO begins a fresh accumulation even if one is already active —
|
||||||
@@ -236,7 +415,7 @@ func (r *Recorder) RestartQSO() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.acc = append([]int16(nil), r.ring...)
|
r.acc = append([]int16(nil), r.ring...)
|
||||||
r.active = true
|
r.active, r.paused = true, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetQSOClock restarts the active accumulation from ZERO — discarding
|
// 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")
|
return nil, fmt.Errorf("no active recording")
|
||||||
}
|
}
|
||||||
samples := r.acc
|
samples := r.acc
|
||||||
r.acc, r.active = nil, false
|
r.acc, r.active, r.paused = nil, false, false
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
if len(samples) == 0 {
|
if len(samples) == 0 {
|
||||||
return nil, fmt.Errorf("recording was empty")
|
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).
|
// DiscardQSO drops the active accumulation without saving (callsign cleared).
|
||||||
func (r *Recorder) DiscardQSO() {
|
func (r *Recorder) DiscardQSO() {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.acc, r.active = nil, false
|
r.acc, r.active, r.paused = nil, false, false
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +492,7 @@ func (r *Recorder) Stop() {
|
|||||||
close(stop)
|
close(stop)
|
||||||
r.wg.Wait()
|
r.wg.Wait()
|
||||||
r.mu.Lock()
|
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.mu.Unlock()
|
||||||
r.srcMu.Lock()
|
r.srcMu.Lock()
|
||||||
r.bufA, r.bufB = nil, nil
|
r.bufA, r.bufB = nil, nil
|
||||||
@@ -346,3 +525,55 @@ func int16sToBytes(s []int16) []byte {
|
|||||||
}
|
}
|
||||||
return b
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
-6
@@ -75,10 +75,22 @@ type Def struct {
|
|||||||
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
||||||
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
||||||
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
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
|
// OneRefPerQSO refuses an AMBIGUOUS match rather than guessing.
|
||||||
// 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
|
// This is not the old `Multi` flag, which was removed for good reason: a QSO
|
||||||
// `Multi` flag was read by nothing; its checkbox changed nothing.
|
// 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
|
// 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
|
// tried IN ORDER and only while nothing has matched yet — the first rule that
|
||||||
@@ -341,6 +353,12 @@ type Ref struct {
|
|||||||
Worked bool `json:"worked"`
|
Worked bool `json:"worked"`
|
||||||
Confirmed bool `json:"confirmed"`
|
Confirmed bool `json:"confirmed"`
|
||||||
Validated bool `json:"validated"`
|
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"`
|
Bands []string `json:"bands"`
|
||||||
ConfirmedBands []string `json:"confirmed_bands"`
|
ConfirmedBands []string `json:"confirmed_bands"`
|
||||||
ValidatedBands []string `json:"validated_bands"`
|
ValidatedBands []string `json:"validated_bands"`
|
||||||
@@ -360,11 +378,33 @@ type Result struct {
|
|||||||
Error string `json:"error,omitempty"` // e.g. bad regexp pattern
|
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.
|
// NameResolver optionally maps a (field, ref) pair to a human name. May be nil.
|
||||||
type NameResolver func(field, ref string) string
|
type NameResolver func(field, ref string) string
|
||||||
|
|
||||||
type refAgg struct {
|
type refAgg struct {
|
||||||
bands map[string]struct{}
|
bands map[string]struct{}
|
||||||
|
modes map[string]struct{}
|
||||||
|
confirmedModes map[string]struct{}
|
||||||
confirmedBands map[string]struct{}
|
confirmedBands map[string]struct{}
|
||||||
validatedBands map[string]struct{}
|
validatedBands map[string]struct{}
|
||||||
anyConfirmed bool
|
anyConfirmed bool
|
||||||
@@ -481,22 +521,33 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
band := strings.ToLower(strings.TrimSpace(q.Band))
|
band := strings.ToLower(strings.TrimSpace(q.Band))
|
||||||
|
modeClass := ModeClass(q.Mode)
|
||||||
isConf := confirmed(q, d.Confirm)
|
isConf := confirmed(q, d.Confirm)
|
||||||
isVal := confirmed(q, d.Validate)
|
isVal := confirmed(q, d.Validate)
|
||||||
for _, ref := range refs {
|
for _, ref := range refs {
|
||||||
a := agg[i][ref]
|
a := agg[i][ref]
|
||||||
if a == nil {
|
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
|
agg[i][ref] = a
|
||||||
}
|
}
|
||||||
if band != "" {
|
if band != "" {
|
||||||
a.bands[band] = struct{}{}
|
a.bands[band] = struct{}{}
|
||||||
}
|
}
|
||||||
|
if modeClass != "" {
|
||||||
|
a.modes[modeClass] = struct{}{}
|
||||||
|
}
|
||||||
if isConf {
|
if isConf {
|
||||||
a.anyConfirmed = true
|
a.anyConfirmed = true
|
||||||
if band != "" {
|
if band != "" {
|
||||||
a.confirmedBands[band] = struct{}{}
|
a.confirmedBands[band] = struct{}{}
|
||||||
}
|
}
|
||||||
|
if modeClass != "" {
|
||||||
|
a.confirmedModes[modeClass] = struct{}{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if isVal {
|
if isVal {
|
||||||
a.anyValidated = true
|
a.anyValidated = true
|
||||||
@@ -525,6 +576,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
|||||||
r.Validated++
|
r.Validated++
|
||||||
}
|
}
|
||||||
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
|
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)}
|
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands), ValidatedBands: setToSorted(a.validatedBands)}
|
||||||
labelRef(&rf, d, ref, rl, hasList, nameOf)
|
labelRef(&rf, d, ref, rl, hasList, nameOf)
|
||||||
r.Refs = append(r.Refs, rf)
|
r.Refs = append(r.Refs, rf)
|
||||||
@@ -819,6 +871,14 @@ type Explanation struct {
|
|||||||
RefCount int `json:"ref_count"` // size of that list
|
RefCount int `json:"ref_count"` // size of that list
|
||||||
Steps []Step `json:"steps"`
|
Steps []Step `json:"steps"`
|
||||||
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
|
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
|
||||||
|
// 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
|
Result []string `json:"result"` // what the QSO finally counts for
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -937,8 +997,31 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
|
|||||||
if ex != nil {
|
if ex != nil {
|
||||||
ex.Manual = manual
|
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)
|
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 {
|
if ex != nil {
|
||||||
ex.Result = out
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-4
@@ -160,16 +160,29 @@ func FreqToBCD(hz int64) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BCDToFreq decodes Icom little-endian BCD frequency bytes back to Hz.
|
// BCDToFreq decodes Icom little-endian BCD frequency bytes back to Hz.
|
||||||
func BCDToFreq(b []byte) int64 {
|
//
|
||||||
|
// It returns ok=false when a nibble is not a decimal digit. That check is the
|
||||||
|
// point of the function: a CI-V byte stream that has lost frame sync — a reply
|
||||||
|
// read at the wrong offset, a collision with the scope stream — still decodes
|
||||||
|
// into a number if 0x0A..0x0F are quietly treated as 10..15. An operator on a
|
||||||
|
// 2 m FM channel then saw entries like 16445550000 Hz appear in the status bar
|
||||||
|
// between good readings (F4JND, 2026-07-30). Garbage that LOOKS like a
|
||||||
|
// frequency is worse than a refused frame: it reaches the display, the band
|
||||||
|
// slots, and eventually the log.
|
||||||
|
func BCDToFreq(b []byte) (int64, bool) {
|
||||||
var hz int64
|
var hz int64
|
||||||
mult := int64(1)
|
mult := int64(1)
|
||||||
for i := 0; i < len(b) && i < 5; i++ {
|
for i := 0; i < len(b) && i < 5; i++ {
|
||||||
hz += int64(b[i]&0x0F) * mult
|
lo, hi := b[i]&0x0F, b[i]>>4
|
||||||
|
if lo > 9 || hi > 9 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
hz += int64(lo) * mult
|
||||||
mult *= 10
|
mult *= 10
|
||||||
hz += int64(b[i]>>4) * mult
|
hz += int64(hi) * mult
|
||||||
mult *= 10
|
mult *= 10
|
||||||
}
|
}
|
||||||
return hz
|
return hz, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// LevelToBCD encodes a 0-255 level as the 2 big-endian BCD bytes Icom's
|
// LevelToBCD encodes a 0-255 level as the 2 big-endian BCD bytes Icom's
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ func TestFreqBCDRoundTrip(t *testing.T) {
|
|||||||
if len(b) != 5 {
|
if len(b) != 5 {
|
||||||
t.Fatalf("FreqToBCD(%d) len=%d, want 5", hz, len(b))
|
t.Fatalf("FreqToBCD(%d) len=%d, want 5", hz, len(b))
|
||||||
}
|
}
|
||||||
if got := BCDToFreq(b); got != hz {
|
got, ok := BCDToFreq(b)
|
||||||
t.Errorf("round trip %d → % X → %d", hz, b, got)
|
if !ok || got != hz {
|
||||||
|
t.Errorf("round trip %d → % X → %d (ok=%v)", hz, b, got, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,8 +50,8 @@ func TestScanSingleFreqResponse(t *testing.T) {
|
|||||||
if f.From != 0x98 || f.To != AddrController || f.Cmd != CmdReadFreq {
|
if f.From != 0x98 || f.To != AddrController || f.Cmd != CmdReadFreq {
|
||||||
t.Errorf("addrs/cmd wrong: %+v", f)
|
t.Errorf("addrs/cmd wrong: %+v", f)
|
||||||
}
|
}
|
||||||
if hz := BCDToFreq(f.Data); hz != 14250000 {
|
if hz, ok := BCDToFreq(f.Data); !ok || hz != 14250000 {
|
||||||
t.Errorf("decoded freq %d, want 14250000", hz)
|
t.Errorf("decoded freq %d (ok=%v), want 14250000", hz, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,3 +164,43 @@ func TestModelNameAddresses(t *testing.T) {
|
|||||||
t.Errorf("ModelName(0x42) = %q, want the hex fallback", got)
|
t.Errorf("ModelName(0x42) = %q, want the hex fallback", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A frame that is not decimal BCD is refused rather than decoded.
|
||||||
|
//
|
||||||
|
// From an operator's log (F4JND, 2026-07-30): a rig sitting on 145.550 MHz FM
|
||||||
|
// produced status lines reading 16445550000, 8364550000, 5817408364 and 12640 Hz
|
||||||
|
// between good readings. CI-V had lost frame sync — a reply read at the wrong
|
||||||
|
// offset, alongside PTT timeouts and a scope stream the rig was rejecting — and
|
||||||
|
// the decoder treated the nibbles 0x0A..0x0F as the digits 10..15, turning noise
|
||||||
|
// into a plausible-looking number.
|
||||||
|
//
|
||||||
|
// Garbage that looks like a frequency is worse than a refused frame: it reaches
|
||||||
|
// the status bar, the band slots and the log, and it is what an operator then
|
||||||
|
// has to disprove.
|
||||||
|
func TestBCDToFreqRejectsNonDecimal(t *testing.T) {
|
||||||
|
// Valid: 145.550 MHz, encoded by the same package rather than by hand — my
|
||||||
|
// hand-written fixture was byte-reversed and this caught it.
|
||||||
|
good := FreqToBCD(145550000)
|
||||||
|
if hz, ok := BCDToFreq(good); !ok || hz != 145550000 {
|
||||||
|
t.Errorf("valid BCD % X decoded as %d (ok=%v), want 145550000", good, hz, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every nibble value above 9 must be refused, in either half of the byte.
|
||||||
|
bad := [][]byte{
|
||||||
|
{0x0A, 0x00, 0x55, 0x45, 0x01}, // low nibble
|
||||||
|
{0x00, 0x00, 0xF5, 0x45, 0x01}, // high nibble
|
||||||
|
{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, // an idle line read as data
|
||||||
|
{0x00, 0x00, 0x00, 0x0B, 0x00},
|
||||||
|
}
|
||||||
|
for _, b := range bad {
|
||||||
|
if hz, ok := BCDToFreq(b); ok {
|
||||||
|
t.Errorf("% X was accepted as %d Hz — it is not decimal BCD", b, hz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A short frame still decodes what it holds: the caller decides whether a
|
||||||
|
// partial read is usable, and refusing it here would break the 4-byte forms.
|
||||||
|
if _, ok := BCDToFreq([]byte{0x00, 0x50}); !ok {
|
||||||
|
t.Error("a short but valid BCD frame must still decode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+75
-4
@@ -36,6 +36,11 @@ type Flex struct {
|
|||||||
gotHandle bool
|
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)
|
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
||||||
amp flexAmp // external amplifier (PowerGenius XL) state
|
amp flexAmp // external amplifier (PowerGenius XL) state
|
||||||
micProfiles []string // available mic profiles (SmartSDR "profile mic list")
|
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{},
|
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{},
|
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||||
|
pinnedSlice: -1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,7 +974,17 @@ func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
|||||||
// map order returned a RANDOM active slice each call → the operating frequency
|
// 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.
|
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
|
||||||
// Deterministic order = the lowest-indexed active slice wins, stably.
|
// 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
|
firstInUse := -1
|
||||||
|
chosen := -1
|
||||||
for _, idx := range f.sortedSliceIdxLocked() {
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
s := f.slices[idx]
|
s := f.slices[idx]
|
||||||
if !s.inUse {
|
if !s.inUse {
|
||||||
@@ -978,12 +994,57 @@ func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
|||||||
firstInUse = idx
|
firstInUse = idx
|
||||||
}
|
}
|
||||||
if s.active {
|
if s.active {
|
||||||
|
chosen = idx
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 idx, s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if firstInUse >= 0 {
|
|
||||||
return firstInUse, f.slices[firstInUse]
|
|
||||||
}
|
|
||||||
return -1, nil
|
return -1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1089,6 +1150,11 @@ func (f *Flex) SetActiveSlice(idx int) error {
|
|||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("flex: no slice %d", idx)
|
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))
|
f.send(fmt.Sprintf("slice s %d active=1", idx))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1389,6 +1455,7 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Ints(sidx)
|
sort.Ints(sidx)
|
||||||
|
mainIdx, _ := f.mainSliceLocked()
|
||||||
for _, i := range sidx {
|
for _, i := range sidx {
|
||||||
s := f.slices[i]
|
s := f.slices[i]
|
||||||
st.Slices = append(st.Slices, FlexSliceInfo{
|
st.Slices = append(st.Slices, FlexSliceInfo{
|
||||||
@@ -1397,7 +1464,11 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
FreqHz: s.freqHz,
|
FreqHz: s.freqHz,
|
||||||
Mode: flexModeToADIF(s.mode),
|
Mode: flexModeToADIF(s.mode),
|
||||||
Band: BandFromHz(s.freqHz),
|
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,
|
TX: s.tx,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -557,7 +557,9 @@ func (b *IcomSerial) write(payload ...byte) error {
|
|||||||
// recv() only sees the reply to THIS request (avoids a previous command's ack
|
// recv() only sees the reply to THIS request (avoids a previous command's ack
|
||||||
// or an unsolicited dial-turn update being mistaken for our response).
|
// or an unsolicited dial-turn update being mistaken for our response).
|
||||||
b.drainResp()
|
b.drainResp()
|
||||||
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, payload...))
|
frame := civ.Frame(b.rigAddr, civ.AddrController, payload...)
|
||||||
|
traceCIV("TX", frame)
|
||||||
|
_, err := b.port.Write(frame)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,6 +605,9 @@ func (b *IcomSerial) reader(port civTransport, done chan struct{}) {
|
|||||||
continue // read timeout with no data
|
continue // read timeout with no data
|
||||||
}
|
}
|
||||||
rx = append(rx, tmp[:n]...)
|
rx = append(rx, tmp[:n]...)
|
||||||
|
// Traced BEFORE framing: the bytes as they arrived are what shows a lost
|
||||||
|
// frame boundary, which a decoded view would hide.
|
||||||
|
traceCIV("RX", tmp[:n])
|
||||||
frames, consumed := civ.Scan(rx)
|
frames, consumed := civ.Scan(rx)
|
||||||
if consumed > 0 {
|
if consumed > 0 {
|
||||||
rx = append(rx[:0], rx[consumed:]...)
|
rx = append(rx[:0], rx[consumed:]...)
|
||||||
@@ -750,8 +755,13 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
|||||||
// (e.g. 14.200 MHz + 100 kHz → centred 14.150-14.250; 21.000 +
|
// (e.g. 14.200 MHz + 100 kHz → centred 14.150-14.250; 21.000 +
|
||||||
// 21.070 → fixed 21.000-21.070). Guessing wrong here gave the absurd
|
// 21.070 → fixed 21.000-21.070). Guessing wrong here gave the absurd
|
||||||
// 21.000-42.070 span (low + a 21 MHz "span").
|
// 21.000-42.070 span (low + a 21 MHz "span").
|
||||||
v1 := civ.BCDToFreq(region[1:6])
|
v1, ok1 := civ.BCDToFreq(region[1:6])
|
||||||
v2 := civ.BCDToFreq(region[6:11])
|
v2, ok2 := civ.BCDToFreq(region[6:11])
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
// Not decimal BCD — the frame was read at the wrong offset. Keep the
|
||||||
|
// edges we had; a wrong span is drawn, noticed, and mistrusted.
|
||||||
|
break
|
||||||
|
}
|
||||||
var low, high int64
|
var low, high int64
|
||||||
if v2 > v1 {
|
if v2 > v1 {
|
||||||
low, high = v1, v2 // absolute low/high edges (fixed edge set)
|
low, high = v1, v2 // absolute low/high edges (fixed edge set)
|
||||||
@@ -788,8 +798,13 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
|||||||
// high = span (e.g. 100 kHz), so high < low and the panadapter had
|
// high = span (e.g. 100 kHz), so high < low and the panadapter had
|
||||||
// no valid range to map the trace onto → blank scope. FIXED mode
|
// no valid range to map the trace onto → blank scope. FIXED mode
|
||||||
// parsed fine, which is why only center mode was broken.
|
// parsed fine, which is why only center mode was broken.
|
||||||
v1 := civ.BCDToFreq(region[1:6])
|
v1, ok1 := civ.BCDToFreq(region[1:6])
|
||||||
v2 := civ.BCDToFreq(region[6:11])
|
v2, ok2 := civ.BCDToFreq(region[6:11])
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
// Not decimal BCD — the frame was read at the wrong offset. Keep the
|
||||||
|
// edges we had; a wrong span is drawn, noticed, and mistrusted.
|
||||||
|
break
|
||||||
|
}
|
||||||
var low, high int64
|
var low, high int64
|
||||||
if v2 > v1 {
|
if v2 > v1 {
|
||||||
low, high = v1, v2 // absolute low/high edges (fixed)
|
low, high = v1, v2 // absolute low/high edges (fixed)
|
||||||
@@ -1142,7 +1157,14 @@ func (b *IcomSerial) readFreq() (int64, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return civ.BCDToFreq(f.Data), nil
|
hz, ok := civ.BCDToFreq(f.Data)
|
||||||
|
if !ok {
|
||||||
|
// A frame that is not decimal BCD is a desynchronised read, not a
|
||||||
|
// frequency. Reporting the error keeps the caller on its last good value
|
||||||
|
// instead of publishing a number like 16445550000 Hz to the display.
|
||||||
|
return 0, fmt.Errorf("icom: frequency frame is not BCD: % X", f.Data)
|
||||||
|
}
|
||||||
|
return hz, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readSplit reads the rig's split state (CI-V 0x0F). 0x01 = split on; 0x10/0x11
|
// readSplit reads the rig's split state (CI-V 0x0F). 0x01 = split on; 0x10/0x11
|
||||||
@@ -1172,7 +1194,11 @@ func (b *IcomSerial) readTXFreq() (int64, bool) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
return civ.BCDToFreq(f.Data[1:]), true
|
hz, ok := civ.BCDToFreq(f.Data[1:])
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return hz, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// readTX reads the transmit state (CI-V 0x1C 0x00): non-zero data = keyed.
|
// readTX reads the transmit state (CI-V 0x1C 0x00): non-zero data = keyed.
|
||||||
|
|||||||
@@ -0,0 +1,580 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
// Native Kenwood CAT — TS-590, TS-890, TS-2000 and the many rigs that speak the
|
||||||
|
// same dialect (Elecraft K3/K4, and the "Kenwood/Elecraft" setting on Flex and
|
||||||
|
// SunSDR). Plain ASCII, every command terminated by ';', same shape as Yaesu but
|
||||||
|
// a different vocabulary.
|
||||||
|
//
|
||||||
|
// The dialect is already proven in this repository from the other side:
|
||||||
|
// internal/catemu ANSWERS these commands, pretending to be a TS-2000 so an ACOM
|
||||||
|
// amplifier follows OpsLog. The frame layouts here and there are the same ones.
|
||||||
|
//
|
||||||
|
// Commands used:
|
||||||
|
//
|
||||||
|
// FA; → FA00014025000; VFO A frequency, ELEVEN digits, Hz
|
||||||
|
// FB; → FB00014030000; VFO B frequency
|
||||||
|
// IF; → 38-char status frame: frequency, RX/TX, mode, VFO, split —
|
||||||
|
// the whole operating state in ONE round trip, which is why it
|
||||||
|
// is the poll rather than asking four separate questions.
|
||||||
|
// MD; → MD3; mode (1=LSB 2=USB 3=CW 4=FM 5=AM 6=FSK
|
||||||
|
// 7=CW-R 9=FSK-R)
|
||||||
|
// FR0;/FR1; receive VFO — 0 = A, 1 = B
|
||||||
|
// FT0;/FT1; transmit VFO (split = the two differ)
|
||||||
|
// TX;/RX; key / unkey
|
||||||
|
// ID; → ID020; model number
|
||||||
|
// AI0; silence unsolicited status reports
|
||||||
|
//
|
||||||
|
// Why not OmniRig: the same reason the Yaesu backend exists. Every Kenwood
|
||||||
|
// fault reported through OmniRig came from its interpretation layer rather than
|
||||||
|
// from the radio, and the rig file decides what a "Freq" property means.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kenwood is the native backend. One serial port, one mutex: a command and its
|
||||||
|
// reply are never interleaved with another exchange.
|
||||||
|
type Kenwood struct {
|
||||||
|
portName string
|
||||||
|
baud int
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
if baud <= 0 {
|
||||||
|
baud = 9600 // TS-590 factory default; TS-890 ships at 115200
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(digital) == "" {
|
||||||
|
digital = "FT8"
|
||||||
|
}
|
||||||
|
return &Kenwood{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Kenwood) Name() string { return "kenwood" }
|
||||||
|
|
||||||
|
func (k *Kenwood) Connect() error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
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
|
||||||
|
// handle makes the next open fail with "port busy" (the fault found in the
|
||||||
|
// Yaesu backend — same shape here).
|
||||||
|
if k.port != nil {
|
||||||
|
_ = k.port.Close()
|
||||||
|
k.port = nil
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
k.port = p
|
||||||
|
k.unsupported = map[string]bool{}
|
||||||
|
|
||||||
|
// Silence unsolicited status reports: they interleave with our
|
||||||
|
// request/response pairs and make a reply impossible to attribute. We poll.
|
||||||
|
_ = k.write("AI0;")
|
||||||
|
|
||||||
|
answered := false
|
||||||
|
if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") {
|
||||||
|
answered = true
|
||||||
|
code := strings.TrimSuffix(strings.TrimPrefix(id, "ID"), ";")
|
||||||
|
if name, ok := kenwoodModels[code]; ok {
|
||||||
|
k.model = name
|
||||||
|
} else {
|
||||||
|
k.model = "Kenwood (" + code + ")"
|
||||||
|
debugLog.Printf("kenwood: unknown model id %q — add it to kenwoodModels", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// IF is the command everything else depends on, so it is also the honest
|
||||||
|
// test of whether a radio is really there.
|
||||||
|
if r, err := k.ask("IF;"); err == nil && strings.HasPrefix(r, "IF") {
|
||||||
|
answered = true
|
||||||
|
}
|
||||||
|
if !answered {
|
||||||
|
k.model = ""
|
||||||
|
if k.host != "" {
|
||||||
|
return fmt.Errorf("kenwood: %s accepted the connection but the rig is not answering — 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 is not answering — 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)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Kenwood) Disconnect() {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port != nil {
|
||||||
|
_ = k.port.Close()
|
||||||
|
k.port = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadState polls the rig. IF carries frequency, mode, VFO, split and TX state
|
||||||
|
// in one frame; the other VFO is only asked for when split is actually on, so
|
||||||
|
// the common simplex case costs a single round trip.
|
||||||
|
func (k *Kenwood) ReadState() (RigState, error) {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return RigState{}, fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
raw, err := k.ask("IF;")
|
||||||
|
if err != nil {
|
||||||
|
return RigState{}, err
|
||||||
|
}
|
||||||
|
f, ok := parseKenwoodIF(raw)
|
||||||
|
if !ok {
|
||||||
|
return RigState{}, fmt.Errorf("kenwood: unparsable IF frame %q", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := RigState{Connected: true, Backend: "kenwood"}
|
||||||
|
k.curVFO = f.VFO
|
||||||
|
s.Vfo = f.VFO
|
||||||
|
s.Mode = kenwoodModeToADIF(f.Mode, k.digital)
|
||||||
|
s.Rig = k.model
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// into the log, which is worse than showing no split at all.
|
||||||
|
other := "FB;"
|
||||||
|
if f.VFO == "B" {
|
||||||
|
other = "FA;"
|
||||||
|
}
|
||||||
|
if r, err := k.ask(other); err == nil {
|
||||||
|
if hz, ok := parseKenwoodFreq(r, strings.TrimSuffix(other, ";")); ok && hz > 0 && hz != rx {
|
||||||
|
tx = hz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tx != rx {
|
||||||
|
s.FreqHz = tx // ADIF: FREQ is the TRANSMIT frequency
|
||||||
|
s.RxFreqHz = rx
|
||||||
|
s.Split = true
|
||||||
|
} else {
|
||||||
|
s.FreqHz = rx
|
||||||
|
}
|
||||||
|
k.curFreq = s.FreqHz
|
||||||
|
if s.Split {
|
||||||
|
k.curRXFreq = s.RxFreqHz
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFrequency tunes the VFO the operator is actually on — writing FA blind is
|
||||||
|
// what makes a display disagree with the radio when they are on B.
|
||||||
|
func (k *Kenwood) SetFrequency(hz int64) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
if hz <= 0 || hz > 99_999_999_999 {
|
||||||
|
return fmt.Errorf("kenwood: frequency %d out of the 11-digit CAT range", hz)
|
||||||
|
}
|
||||||
|
cmd := "FA"
|
||||||
|
if k.curVFO == "B" {
|
||||||
|
cmd = "FB"
|
||||||
|
}
|
||||||
|
return k.write(fmt.Sprintf("%s%011d;", cmd, hz))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Kenwood) SetMode(mode string) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
d := kenwoodModeDigit(mode, k.curFreq)
|
||||||
|
if d == 0 {
|
||||||
|
return fmt.Errorf("kenwood: no CAT mode for %q", mode)
|
||||||
|
}
|
||||||
|
return k.write(fmt.Sprintf("MD%c;", d))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Kenwood) SetPTT(on bool) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
if on {
|
||||||
|
return k.write("TX;")
|
||||||
|
}
|
||||||
|
return k.write("RX;")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// for no reason (found the hard way on the Yaesu backend).
|
||||||
|
func (k *Kenwood) ask(cmd string) (string, error) {
|
||||||
|
want := cmdPrefix(cmd)
|
||||||
|
if k.unsupported[want] {
|
||||||
|
return "", fmt.Errorf("kenwood: %s is not supported by this rig", want)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
i := strings.IndexByte(string(buf), ';')
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
frame := string(buf[:i+1])
|
||||||
|
buf = buf[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.
|
||||||
|
k.unsupported[want] = true
|
||||||
|
debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd)
|
||||||
|
return "", fmt.Errorf("kenwood: %s rejected", want)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(frame, want) {
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Frame parsing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// kenwoodIF is what the 38-character IF status frame carries.
|
||||||
|
type kenwoodIF struct {
|
||||||
|
FreqHz int64
|
||||||
|
Mode byte
|
||||||
|
VFO string // "A" or "B"
|
||||||
|
Split bool
|
||||||
|
// TX is parsed even though RigState has no PTT field: it is one character of
|
||||||
|
// the same frame, and having it here means a future "transmitting" indicator
|
||||||
|
// costs no extra round trip.
|
||||||
|
TX bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseKenwoodIF reads the TS-2000/TS-590 status frame. Its layout — the same
|
||||||
|
// one internal/catemu emits — is:
|
||||||
|
//
|
||||||
|
// IF | freq(11) | step(4) | RIT(±5) | RIT/XIT/bank(3) | mem(2) | rx-tx(1) |
|
||||||
|
// mode(1) | VFO(1) | scan(1) | split(1) | tone(1) | tone#(2) | shift(1) | ;
|
||||||
|
//
|
||||||
|
// Fields are read by POSITION, so the length is checked first: a short frame
|
||||||
|
// means a truncated read, and indexing into it would panic or, worse, silently
|
||||||
|
// yield a wrong frequency.
|
||||||
|
func parseKenwoodIF(reply string) (kenwoodIF, bool) {
|
||||||
|
r := strings.TrimSpace(reply)
|
||||||
|
if !strings.HasPrefix(r, "IF") || len(r) < 38 {
|
||||||
|
return kenwoodIF{}, false
|
||||||
|
}
|
||||||
|
hz, err := strconv.ParseInt(strings.TrimSpace(r[2:13]), 10, 64)
|
||||||
|
if err != nil || hz <= 0 {
|
||||||
|
return kenwoodIF{}, false
|
||||||
|
}
|
||||||
|
out := kenwoodIF{FreqHz: hz, Mode: r[29], VFO: "A", TX: r[28] == '1', Split: r[32] == '1'}
|
||||||
|
if r[30] == '1' {
|
||||||
|
out.VFO = "B"
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseKenwoodFreq reads an FA/FB reply — ELEVEN digits on Kenwood, where Yaesu
|
||||||
|
// uses nine. The prefix is checked so an FB reply is never accepted as FA.
|
||||||
|
func parseKenwoodFreq(reply, prefix string) (int64, bool) {
|
||||||
|
r := strings.TrimSpace(reply)
|
||||||
|
if !strings.HasPrefix(r, prefix) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
digits := strings.TrimSuffix(strings.TrimPrefix(r, prefix), ";")
|
||||||
|
if digits == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
hz, err := strconv.ParseInt(digits, 10, 64)
|
||||||
|
if err != nil || hz <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return hz, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modes ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// kenwoodModeDigit maps an ADIF mode to the Kenwood digit. The sideband follows
|
||||||
|
// the frequency by worldwide convention — a backend that puts USB on 40 m makes
|
||||||
|
// every SSB QSO in the log wrong.
|
||||||
|
func kenwoodModeDigit(mode string, hz int64) byte {
|
||||||
|
m := strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
switch m {
|
||||||
|
case "":
|
||||||
|
return 0
|
||||||
|
case "LSB":
|
||||||
|
return '1'
|
||||||
|
case "USB":
|
||||||
|
return '2'
|
||||||
|
case "CW":
|
||||||
|
return '3'
|
||||||
|
case "CW-R", "CWR":
|
||||||
|
return '7'
|
||||||
|
case "FM":
|
||||||
|
return '4'
|
||||||
|
case "AM":
|
||||||
|
return '5'
|
||||||
|
case "RTTY", "FSK":
|
||||||
|
return '6'
|
||||||
|
case "RTTY-R", "FSK-R":
|
||||||
|
return '9'
|
||||||
|
case "SSB":
|
||||||
|
if hz > 0 && hz < 10_000_000 {
|
||||||
|
return '1'
|
||||||
|
}
|
||||||
|
return '2'
|
||||||
|
}
|
||||||
|
// Any other digital mode rides on the data sideband, which on Kenwood is
|
||||||
|
// plain USB/LSB with the rig's DATA input selected.
|
||||||
|
if hz > 0 && hz < 10_000_000 {
|
||||||
|
return '1'
|
||||||
|
}
|
||||||
|
return '2'
|
||||||
|
}
|
||||||
|
|
||||||
|
// kenwoodModeToADIF turns the rig's mode digit into what the log records.
|
||||||
|
// Digital modes are indistinguishable from SSB over CAT — the rig only knows
|
||||||
|
// it is on USB — so the operator's configured digital mode is used, exactly as
|
||||||
|
// the other backends do.
|
||||||
|
func kenwoodModeToADIF(d byte, digital string) string {
|
||||||
|
switch d {
|
||||||
|
case '1':
|
||||||
|
return "LSB"
|
||||||
|
case '2':
|
||||||
|
return "USB"
|
||||||
|
case '3', '7':
|
||||||
|
return "CW"
|
||||||
|
case '4':
|
||||||
|
return "FM"
|
||||||
|
case '5':
|
||||||
|
return "AM"
|
||||||
|
case '6', '9':
|
||||||
|
return "RTTY"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// kenwoodModels maps the ID reply to a name for the status bar. An unknown code
|
||||||
|
// is shown as-is rather than refused: the model name is cosmetic, and a rig that
|
||||||
|
// answers everything else must not be rejected over it.
|
||||||
|
var kenwoodModels = map[string]string{
|
||||||
|
"017": "TS-570",
|
||||||
|
"019": "TS-2000",
|
||||||
|
"020": "TS-480",
|
||||||
|
"021": "TS-590S",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
// 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)
|
||||||
|
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,357 @@
|
|||||||
|
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(), "not answering") {
|
||||||
|
t.Errorf("error was %q — it should say the rig is not answering", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The IF status frame, read by POSITION.
|
||||||
|
//
|
||||||
|
// One frame carries frequency, TX state, mode, VFO and split, which is why it is
|
||||||
|
// the poll. Every field is a fixed offset, so a length check comes first: a
|
||||||
|
// truncated read would otherwise index into the wrong characters and yield a
|
||||||
|
// plausible-looking wrong frequency — the worst kind, because it reaches the log.
|
||||||
|
//
|
||||||
|
// The layout is the one internal/catemu already EMITS to satisfy an ACOM
|
||||||
|
// amplifier, so these two halves of the repository agree by construction.
|
||||||
|
func TestParseKenwoodIF(t *testing.T) {
|
||||||
|
// IF | freq(11) | step(4) | RIT(±5) | 3 | mem(2) | rx/tx | mode | VFO | scan |
|
||||||
|
// split | tone | tone#(2) | shift | ; → 38 characters
|
||||||
|
const rx20mUSB = "IF00014250000" + "0000" + "+00000" + "000" + "00" + "0" + "2" + "0" + "0" + "0" + "0" + "00" + "0" + ";"
|
||||||
|
if len(rx20mUSB) != 38 {
|
||||||
|
t.Fatalf("the test's own frame is %d chars, not 38 — fix the fixture first", len(rx20mUSB))
|
||||||
|
}
|
||||||
|
|
||||||
|
f, ok := parseKenwoodIF(rx20mUSB)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("a valid frame was rejected: %q", rx20mUSB)
|
||||||
|
}
|
||||||
|
if f.FreqHz != 14250000 {
|
||||||
|
t.Errorf("frequency = %d, want 14250000", f.FreqHz)
|
||||||
|
}
|
||||||
|
if f.Mode != '2' {
|
||||||
|
t.Errorf("mode = %q, want '2' (USB)", f.Mode)
|
||||||
|
}
|
||||||
|
if f.VFO != "A" || f.Split || f.TX {
|
||||||
|
t.Errorf("got VFO=%s split=%v tx=%v — want A, simplex, receiving", f.VFO, f.Split, f.TX)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same frame transmitting, on VFO B, split, in CW.
|
||||||
|
const txSplitB = "IF00007030000" + "0000" + "+00000" + "000" + "00" + "1" + "3" + "1" + "0" + "1" + "0" + "00" + "0" + ";"
|
||||||
|
f2, ok := parseKenwoodIF(txSplitB)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("valid frame rejected: %q", txSplitB)
|
||||||
|
}
|
||||||
|
if !f2.TX || f2.Mode != '3' || f2.VFO != "B" || !f2.Split {
|
||||||
|
t.Errorf("got tx=%v mode=%q vfo=%s split=%v — want transmitting, CW, B, split",
|
||||||
|
f2.TX, f2.Mode, f2.VFO, f2.Split)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rejections: anything that would make position-reading unsafe.
|
||||||
|
for _, bad := range []string{
|
||||||
|
"",
|
||||||
|
"IF;", // query echoed with no payload
|
||||||
|
"IF0001425000;", // short — the trap this guards against
|
||||||
|
"FA00014250000;", // another command's reply
|
||||||
|
"IF0001425000x0000+00000000000203000000;", // non-numeric frequency
|
||||||
|
} {
|
||||||
|
if _, ok := parseKenwoodIF(bad); ok {
|
||||||
|
t.Errorf("accepted a frame it should have refused: %q", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FA/FB carry ELEVEN digits on Kenwood where Yaesu uses nine — the single most
|
||||||
|
// likely place to copy the Yaesu backend and be wrong by a factor of a hundred.
|
||||||
|
func TestParseKenwoodFreq(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
reply, prefix string
|
||||||
|
want int64
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"FA00014250000;", "FA", 14250000, true},
|
||||||
|
{"FB00007030000;", "FB", 7030000, true},
|
||||||
|
{"FA00000474000;", "FA", 474000, true}, // 630 m — leading zeros must not truncate
|
||||||
|
{"FA10368000000;", "FA", 10368000000, true}, // 3 cm
|
||||||
|
{"FA00014250000;", "FB", 0, false}, // wrong VFO is never silently accepted
|
||||||
|
{"FA;", "FA", 0, false},
|
||||||
|
{"FAxxxxxxxxxxx;", "FA", 0, false},
|
||||||
|
{"", "FA", 0, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, ok := parseKenwoodFreq(c.reply, c.prefix)
|
||||||
|
if got != c.want || ok != c.ok {
|
||||||
|
t.Errorf("parseKenwoodFreq(%q,%q) = %d,%v — want %d,%v", c.reply, c.prefix, got, ok, c.want, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sideband follows the frequency by worldwide convention. A backend that
|
||||||
|
// puts USB on 40 m makes every SSB QSO in the log wrong, which is why this is
|
||||||
|
// pinned rather than left to the rig.
|
||||||
|
func TestKenwoodModeDigit(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
mode string
|
||||||
|
hz int64
|
||||||
|
want byte
|
||||||
|
}{
|
||||||
|
{"SSB", 7150000, '1'}, // LSB below 10 MHz
|
||||||
|
{"SSB", 14250000, '2'}, // USB above
|
||||||
|
{"LSB", 14250000, '1'}, // an explicit choice wins over the convention
|
||||||
|
{"USB", 7150000, '2'},
|
||||||
|
{"CW", 7030000, '3'},
|
||||||
|
{"RTTY", 14080000, '6'},
|
||||||
|
{"AM", 7150000, '5'},
|
||||||
|
{"FM", 145000000, '4'},
|
||||||
|
{"FT8", 7074000, '1'}, // data rides on the sideband for the band
|
||||||
|
{"FT8", 14074000, '2'},
|
||||||
|
{"", 14074000, 0}, // nothing to set
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := kenwoodModeDigit(c.mode, c.hz); got != c.want {
|
||||||
|
t.Errorf("kenwoodModeDigit(%q, %d) = %q, want %q", c.mode, c.hz, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the log records for each mode digit the rig reports.
|
||||||
|
func TestKenwoodModeToADIF(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
d byte
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{'1', "LSB"}, {'2', "USB"},
|
||||||
|
{'3', "CW"}, {'7', "CW"}, // CW-R is still CW in the log
|
||||||
|
{'4', "FM"}, {'5', "AM"},
|
||||||
|
{'6', "RTTY"}, {'9', "RTTY"}, // FSK-R likewise
|
||||||
|
{'0', ""}, // unknown → say nothing rather than guess
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := kenwoodModeToADIF(c.d, "FT8"); got != c.want {
|
||||||
|
t.Errorf("kenwoodModeToADIF(%q) = %q, want %q", c.d, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LogSink, when set by the host app at startup, receives every CAT debug
|
// LogSink, when set by the host app at startup, receives every CAT debug
|
||||||
@@ -73,3 +74,54 @@ func DebugLogPath() string {
|
|||||||
}
|
}
|
||||||
return filepath.Join(base, "OpsLog", "cat.log")
|
return filepath.Join(base, "OpsLog", "cat.log")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CAT wire 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.
|
||||||
|
//
|
||||||
|
// 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 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) {
|
||||||
|
catTrace.Store(on)
|
||||||
|
if on {
|
||||||
|
debugLog.Printf("wire trace ON — every CAT frame to and from the rig is logged")
|
||||||
|
} else {
|
||||||
|
debugLog.Printf("wire trace OFF")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CIVTraceEnabled reports whether the trace is running (for the settings UI).
|
||||||
|
func CIVTraceEnabled() bool { return catTrace.Load() }
|
||||||
|
|
||||||
|
// traceCIV logs one CI-V frame. dir is "TX" (to the rig) or "RX" (from it).
|
||||||
|
func traceCIV(dir string, b []byte) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
+21
-3
@@ -493,13 +493,25 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
|||||||
// method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) —
|
// method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) —
|
||||||
// where direct FreqA/FreqB writes are accepted but never move the radio.
|
// where direct FreqA/FreqB writes are accepted but never move the radio.
|
||||||
// Clearing split is the right thing when tuning to a spot anyway.
|
// Clearing split is the right thing when tuning to a spot anyway.
|
||||||
|
// …but ONLY when the operator is on the main VFO. SetSimplexMode means
|
||||||
|
// "receive and transmit here, simplex", and OmniRig implements that on the
|
||||||
|
// MAIN VFO — so on an FTDX101 listening on SUB, clicking a cluster spot
|
||||||
|
// dragged the radio back to MAIN (F4NBZ, 2026-07-29). The operator picked SUB
|
||||||
|
// deliberately; a spot click is a request to change frequency, not to change
|
||||||
|
// VFO. On SUB the direct FreqB write below does the whole job.
|
||||||
|
onSubVFO := vfo == "B" || vfo == "BB" || vfo == "BA"
|
||||||
simplexOK := false
|
simplexOK := false
|
||||||
|
switch {
|
||||||
|
case onSubVFO:
|
||||||
|
debugLog.Printf("OmniRig.SetFrequency: on VFO %q — skipping SetSimplexMode so the rig stays on SUB", vfo)
|
||||||
|
default:
|
||||||
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
|
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
|
||||||
simplexOK = true
|
simplexOK = true
|
||||||
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
|
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
|
||||||
} else {
|
} else {
|
||||||
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v)", err)
|
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v)", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// On Yaesu / Kenwood (and anything non-Icom), SetSimplexMode frequently returns
|
// On Yaesu / Kenwood (and anything non-Icom), SetSimplexMode frequently returns
|
||||||
// OK but is a SILENT NO-OP — the rig never moves. Confirmed on an FT-891 over
|
// OK but is a SILENT NO-OP — the rig never moves. Confirmed on an FT-891 over
|
||||||
@@ -511,12 +523,18 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
|||||||
isIcom := strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "IC")
|
isIcom := strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "IC")
|
||||||
if !isIcom || !simplexOK {
|
if !isIcom || !simplexOK {
|
||||||
prop := "FreqA"
|
prop := "FreqA"
|
||||||
switch vfo {
|
if onSubVFO {
|
||||||
case "B", "BB", "BA":
|
|
||||||
prop = "FreqB"
|
prop = "FreqB"
|
||||||
}
|
}
|
||||||
okAny := false
|
okAny := false
|
||||||
for _, p := range []string{prop, "Freq"} {
|
// The generic "Freq" property is written too — but NOT on SUB, where it is
|
||||||
|
// the main VFO's frequency on several rig files and would move the VFO the
|
||||||
|
// operator is not using, or pull them back to it.
|
||||||
|
props := []string{prop, "Freq"}
|
||||||
|
if onSubVFO {
|
||||||
|
props = []string{prop}
|
||||||
|
}
|
||||||
|
for _, p := range props {
|
||||||
if _, e := oleutil.PutProperty(o.rig, p, hz32); e != nil {
|
if _, e := oleutil.PutProperty(o.rig, p, hz32); e != nil {
|
||||||
debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s) error: %v", p, e)
|
debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s) error: %v", p, e)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -83,3 +83,45 @@ func TestResolveOmniRigVFOs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which VFO a frequency set writes to, and whether it may call SetSimplexMode.
|
||||||
|
//
|
||||||
|
// Reported on an FTDX101 (F4NBZ, 2026-07-29): listening on SUB, clicking a
|
||||||
|
// cluster spot dragged the radio back to MAIN. SetSimplexMode means "receive and
|
||||||
|
// transmit here, simplex" and OmniRig applies it to the MAIN VFO — so it must
|
||||||
|
// not be used when the operator is on SUB. They chose that VFO deliberately; a
|
||||||
|
// spot click asks for a frequency, not for a VFO change.
|
||||||
|
func TestOmniRigWriteTarget(t *testing.T) {
|
||||||
|
// Mirrors the decision made in SetFrequency.
|
||||||
|
target := func(vfo string) (prop string, simplexAllowed bool) {
|
||||||
|
onSub := vfo == "B" || vfo == "BB" || vfo == "BA"
|
||||||
|
if onSub {
|
||||||
|
return "FreqB", false
|
||||||
|
}
|
||||||
|
return "FreqA", true
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
vfo string
|
||||||
|
prop string
|
||||||
|
simplex bool
|
||||||
|
}{
|
||||||
|
// On the main VFO nothing changes: SetSimplexMode is what moves the Icoms
|
||||||
|
// whose direct writes are ignored.
|
||||||
|
{"", "FreqA", true},
|
||||||
|
{"A", "FreqA", true},
|
||||||
|
{"AA", "FreqA", true},
|
||||||
|
{"AB", "FreqA", true},
|
||||||
|
// On SUB, write FreqB and leave the VFO selection alone.
|
||||||
|
{"B", "FreqB", false},
|
||||||
|
{"BB", "FreqB", false},
|
||||||
|
{"BA", "FreqB", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
prop, simplex := target(c.vfo)
|
||||||
|
if prop != c.prop || simplex != c.simplex {
|
||||||
|
t.Errorf("VFO %q → write %s, simplex=%v; want %s, simplex=%v",
|
||||||
|
c.vfo, prop, simplex, c.prop, c.simplex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+40
-2
@@ -56,6 +56,9 @@ type Xiegu struct {
|
|||||||
// asking every cycle — a Xiegu that has no split must not cost a timeout per
|
// 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.
|
// poll, which would slow the whole loop to a crawl.
|
||||||
splitSupported bool
|
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 {
|
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) Name() string { return "xiegu" }
|
||||||
|
|
||||||
func (x *Xiegu) Connect() error {
|
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)
|
return fmt.Errorf("xiegu: open %s @ %d baud: %w", x.portName, x.baud, err)
|
||||||
}
|
}
|
||||||
p.SetReadTimeout(200 * time.Millisecond)
|
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.port = p
|
||||||
x.splitSupported = true
|
x.splitSupported = true
|
||||||
|
|
||||||
@@ -177,12 +202,25 @@ func (x *Xiegu) SetMode(mode string) error {
|
|||||||
return x.send(civ.CmdSetMode, m)
|
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 {
|
func (x *Xiegu) SetPTT(on bool) error {
|
||||||
x.mu.Lock()
|
x.mu.Lock()
|
||||||
defer x.mu.Unlock()
|
defer x.mu.Unlock()
|
||||||
if x.port == nil {
|
if x.port == nil {
|
||||||
return fmt.Errorf("xiegu: not connected")
|
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)
|
v := byte(0x00)
|
||||||
if on {
|
if on {
|
||||||
v = 0x01
|
v = 0x01
|
||||||
@@ -247,8 +285,8 @@ func (x *Xiegu) readFreq() (int64, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
hz := civ.BCDToFreq(d.Data)
|
hz, ok := civ.BCDToFreq(d.Data)
|
||||||
if hz <= 0 {
|
if !ok || hz <= 0 {
|
||||||
return 0, fmt.Errorf("xiegu: unusable frequency % X", d.Data)
|
return 0, fmt.Errorf("xiegu: unusable frequency % X", d.Data)
|
||||||
}
|
}
|
||||||
return hz, nil
|
return hz, nil
|
||||||
|
|||||||
@@ -76,8 +76,9 @@ func TestXieguFrequencyEncoding(t *testing.T) {
|
|||||||
if len(b) != 5 {
|
if len(b) != 5 {
|
||||||
t.Fatalf("FreqToBCD(%d) produced %d bytes, want 5", hz, len(b))
|
t.Fatalf("FreqToBCD(%d) produced %d bytes, want 5", hz, len(b))
|
||||||
}
|
}
|
||||||
if got := civ.BCDToFreq(b); got != hz {
|
got, ok := civ.BCDToFreq(b)
|
||||||
t.Errorf("round trip %d → % X → %d", hz, b, got)
|
if !ok || got != hz {
|
||||||
|
t.Errorf("round trip %d → % X → %d (ok=%v)", hz, b, got, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+153
-18
@@ -17,8 +17,9 @@ package cat
|
|||||||
//
|
//
|
||||||
// FA; → FA014074000; VFO A frequency, 9 digits, Hz
|
// FA; → FA014074000; VFO A frequency, 9 digits, Hz
|
||||||
// FB; → FB014100000; VFO B frequency
|
// FB; → FB014100000; VFO B frequency
|
||||||
// MD0; → MD02; operating mode of the main receiver
|
// MD0;/MD1; → MD02; operating mode — 0 = main receiver, 1 = sub
|
||||||
// VS; → VS0; which VFO is selected (0=A, 1=B)
|
// FR; → FR1; RECEIVE VFO (0=main/A, 1=sub/B)
|
||||||
|
// VS; → VS0; selected VFO, on models without FR
|
||||||
// ST; → ST1; split (FTDX10/FTDX101)
|
// ST; → ST1; split (FTDX10/FTDX101)
|
||||||
// FT; → FT1; TX VFO (FT-991A/FT-710/FT-891 family)
|
// FT; → FT1; TX VFO (FT-991A/FT-710/FT-891 family)
|
||||||
// TX1; / TX0; key / unkey
|
// TX1; / TX0; key / unkey
|
||||||
@@ -93,6 +94,9 @@ type Yaesu struct {
|
|||||||
// answers. Empty means the rig answered neither, and split is reported as
|
// answers. Empty means the rig answered neither, and split is reported as
|
||||||
// off rather than invented.
|
// off rather than invented.
|
||||||
splitCmd string
|
splitCmd string
|
||||||
|
// rxVFOCmd is "FR" when the rig reports its receive VFO that way, else empty
|
||||||
|
// and VS is used — see ReadState.
|
||||||
|
rxVFOCmd string
|
||||||
|
|
||||||
curFreq int64
|
curFreq int64
|
||||||
curRXFreq int64
|
curRXFreq int64
|
||||||
@@ -131,10 +135,35 @@ func (y *Yaesu) Connect() error {
|
|||||||
if y.portName == "" {
|
if y.portName == "" {
|
||||||
return fmt.Errorf("yaesu: no serial port configured")
|
return fmt.Errorf("yaesu: no serial port configured")
|
||||||
}
|
}
|
||||||
|
// Close any handle we still hold before opening another.
|
||||||
|
//
|
||||||
|
// Connect is called again on every reconnect, and it used to overwrite y.port
|
||||||
|
// with a fresh handle and leak the old one. Windows opens a serial port
|
||||||
|
// EXCLUSIVELY, so a leaked handle makes the next open fail with "Serial port
|
||||||
|
// busy" — seen in the field — and the retry loop then leaks one handle per
|
||||||
|
// attempt, once every few seconds, for as long as it keeps failing.
|
||||||
|
if y.port != nil {
|
||||||
|
_ = y.port.Close()
|
||||||
|
y.port = nil
|
||||||
|
}
|
||||||
p, err := serial.Open(y.portName, &serial.Mode{BaudRate: y.baud})
|
p, err := serial.Open(y.portName, &serial.Mode{BaudRate: y.baud})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
|
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
|
||||||
}
|
}
|
||||||
|
// 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)
|
||||||
p.SetReadTimeout(300 * time.Millisecond)
|
p.SetReadTimeout(300 * time.Millisecond)
|
||||||
y.port = p
|
y.port = p
|
||||||
|
|
||||||
@@ -143,7 +172,9 @@ func (y *Yaesu) Connect() error {
|
|||||||
// a reply impossible to attribute — we poll instead, so the traffic is ours.
|
// a reply impossible to attribute — we poll instead, so the traffic is ours.
|
||||||
_ = y.write("AI0;")
|
_ = y.write("AI0;")
|
||||||
|
|
||||||
|
answered := false
|
||||||
if id, err := y.ask("ID;"); err == nil {
|
if id, err := y.ask("ID;"); err == nil {
|
||||||
|
answered = true
|
||||||
code := strings.TrimSuffix(strings.TrimPrefix(id, "ID"), ";")
|
code := strings.TrimSuffix(strings.TrimPrefix(id, "ID"), ";")
|
||||||
if name, ok := yaesuModels[code]; ok {
|
if name, ok := yaesuModels[code]; ok {
|
||||||
y.model = name
|
y.model = name
|
||||||
@@ -159,8 +190,34 @@ func (y *Yaesu) Connect() error {
|
|||||||
// remembering the answer keeps the poll loop from paying for two round trips
|
// remembering the answer keeps the poll loop from paying for two round trips
|
||||||
// per cycle, and makes "neither answered" an explicit, logged state instead
|
// per cycle, and makes "neither answered" an explicit, logged state instead
|
||||||
// of a silent assumption that split is off.
|
// of a silent assumption that split is off.
|
||||||
for _, c := range []string{"ST", "FT"} {
|
// Which command reports the RECEIVE VFO. FR is the right one where it exists;
|
||||||
|
// VS is a weaker substitute that an FTDX101 answers with the main VFO even
|
||||||
|
// when the operator has moved both RX and TX to sub.
|
||||||
|
if r, err := y.ask("FR;"); err == nil && strings.HasPrefix(r, "FR") {
|
||||||
|
answered = true
|
||||||
|
y.rxVFOCmd = "FR"
|
||||||
|
debugLog.Printf("yaesu: receive VFO is read through FR (answered %q)", r)
|
||||||
|
} else {
|
||||||
|
debugLog.Printf("yaesu: no FR; — falling back to VS for the receive VFO")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which command carries split — and the ORDER matters.
|
||||||
|
//
|
||||||
|
// ST is a bare flag: it says "split", not which VFO transmits. On an FTDX101
|
||||||
|
// with RX and TX both moved to SUB, the rig reports ST1 even though that is
|
||||||
|
// plain simplex on the sub VFO, and OpsLog showed split with the main
|
||||||
|
// frequency as TX (F4NBZ, 2026-07-29). FT names the TRANSMIT VFO, so where the
|
||||||
|
// receive VFO is also known (FR), "split" can be derived from the two: it is
|
||||||
|
// on when they differ. That is a statement about the rig's actual state rather
|
||||||
|
// than a flag whose meaning varies by model, so FT is asked FIRST when FR
|
||||||
|
// answered, and ST remains the fallback for rigs without either.
|
||||||
|
splitProbes := []string{"ST", "FT"}
|
||||||
|
if y.rxVFOCmd != "" {
|
||||||
|
splitProbes = []string{"FT", "ST"}
|
||||||
|
}
|
||||||
|
for _, c := range splitProbes {
|
||||||
if r, err := y.ask(c + ";"); err == nil && strings.HasPrefix(r, c) {
|
if r, err := y.ask(c + ";"); err == nil && strings.HasPrefix(r, c) {
|
||||||
|
answered = true
|
||||||
y.splitCmd = c
|
y.splitCmd = c
|
||||||
debugLog.Printf("yaesu: split is read through %s (answered %q)", c, r)
|
debugLog.Printf("yaesu: split is read through %s (answered %q)", c, r)
|
||||||
break
|
break
|
||||||
@@ -169,6 +226,18 @@ func (y *Yaesu) Connect() error {
|
|||||||
if y.splitCmd == "" {
|
if y.splitCmd == "" {
|
||||||
debugLog.Printf("yaesu: neither ST; nor FT; answered — split will be reported as OFF. Send this log if the rig does have split.")
|
debugLog.Printf("yaesu: neither ST; nor FT; answered — split will be reported as OFF. Send this log if the rig does have split.")
|
||||||
}
|
}
|
||||||
|
// A port that opens is not a rig that is there.
|
||||||
|
//
|
||||||
|
// The log used to announce "connected … model=FTDX101D" after every probe had
|
||||||
|
// timed out, with the model left over from the previous session — an operator
|
||||||
|
// whose radio was simply switched OFF got a line saying it was connected, and
|
||||||
|
// the real cause took a log study to find. Say what actually happened.
|
||||||
|
if !answered {
|
||||||
|
y.model = ""
|
||||||
|
debugLog.Printf("yaesu: %s opens at %d baud but the rig answers nothing — is it powered on, and is its CAT rate %d?",
|
||||||
|
y.portName, y.baud, y.baud)
|
||||||
|
return fmt.Errorf("yaesu: %s opened but the rig is not answering — check that it is switched on", y.portName)
|
||||||
|
}
|
||||||
debugLog.Printf("yaesu: connected on %s @ %d baud, model=%q", y.portName, y.baud, y.model)
|
debugLog.Printf("yaesu: connected on %s @ %d baud, model=%q", y.portName, y.baud, y.model)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -212,18 +281,30 @@ func (y *Yaesu) ReadState() (RigState, error) {
|
|||||||
freqB, _ = parseYaesuFreq(r, "FB")
|
freqB, _ = parseYaesuFreq(r, "FB")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Which VFO the operator is listening on. Unlike OmniRig there is no
|
// Which VFO the operator is LISTENING on.
|
||||||
// interpretation to do: VS answers 0 or 1.
|
//
|
||||||
|
// FR is the command that answers that — it selects the receive VFO — and VS
|
||||||
|
// does not: on an FTDX101 with both RX and TX moved to SUB, VS still reported
|
||||||
|
// the main VFO, so OpsLog displayed VFO A while the operator was entirely on
|
||||||
|
// B. Reported 2026-07-29. FR is asked first and VS is the fallback for models
|
||||||
|
// that do not implement it.
|
||||||
vfo := "A"
|
vfo := "A"
|
||||||
if r, err := y.ask("VS;"); err == nil && len(r) >= 3 && r[2] == '1' {
|
switch {
|
||||||
|
case y.rxVFOCmd != "":
|
||||||
|
if r, err := y.ask(y.rxVFOCmd + ";"); err == nil && yaesuStateDigit(r, y.rxVFOCmd) == '1' {
|
||||||
vfo = "B"
|
vfo = "B"
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
if r, err := y.ask("VS;"); err == nil && yaesuStateDigit(r, "VS") == '1' {
|
||||||
|
vfo = "B"
|
||||||
|
}
|
||||||
|
}
|
||||||
y.curVFO = vfo
|
y.curVFO = vfo
|
||||||
|
|
||||||
split := false
|
split := false
|
||||||
if y.splitCmd != "" {
|
if y.splitCmd != "" {
|
||||||
if r, err := y.ask(y.splitCmd + ";"); err == nil {
|
if r, err := y.ask(y.splitCmd + ";"); err == nil {
|
||||||
split = yaesuSplitOn(r, y.splitCmd)
|
split = yaesuSplitFromReply(r, y.splitCmd, vfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +318,7 @@ func (y *Yaesu) ReadState() (RigState, error) {
|
|||||||
y.curRXFreq = s.RxFreqHz
|
y.curRXFreq = s.RxFreqHz
|
||||||
}
|
}
|
||||||
|
|
||||||
if r, err := y.ask("MD0;"); err == nil && len(r) >= 4 {
|
if r, err := y.ask("MD" + y.modeVFOSuffix() + ";"); err == nil && len(r) >= 4 {
|
||||||
// Keep the RAW mode too: ADIF folds CW-U/CW-L and DATA-U/DATA-L together,
|
// Keep the RAW mode too: ADIF folds CW-U/CW-L and DATA-U/DATA-L together,
|
||||||
// but the panel has to show which sideband the rig is actually on.
|
// but the panel has to show which sideband the rig is actually on.
|
||||||
y.panel.RawMode = yaesuRawModeName(r[3])
|
y.panel.RawMode = yaesuRawModeName(r[3])
|
||||||
@@ -284,7 +365,25 @@ func (y *Yaesu) SetMode(mode string) error {
|
|||||||
if d == 0 {
|
if d == 0 {
|
||||||
return fmt.Errorf("yaesu: no CAT mode for %q", mode)
|
return fmt.Errorf("yaesu: no CAT mode for %q", mode)
|
||||||
}
|
}
|
||||||
return y.write(fmt.Sprintf("MD0%c;", d))
|
return y.write(fmt.Sprintf("MD%s%c;", y.modeVFOSuffix(), d))
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeVFOSuffix picks which receiver the mode commands address: "0" is main,
|
||||||
|
// "1" is sub.
|
||||||
|
//
|
||||||
|
// Both the read and the write used 0 unconditionally. A spot click already tuned
|
||||||
|
// the right VFO — that part was fixed — but then set the MODE on main, so an
|
||||||
|
// operator working from the sub receiver had a spot change the mode of the VFO
|
||||||
|
// they were not using and leave the one they were on untouched (F4NBZ,
|
||||||
|
// 2026-07-29). The rig reads the same way, so the panel showed main's mode too.
|
||||||
|
//
|
||||||
|
// Rigs without a sub receiver simply never see "1": curVFO only becomes B on a
|
||||||
|
// rig that reports its receive VFO.
|
||||||
|
func (y *Yaesu) modeVFOSuffix() string {
|
||||||
|
if y.curVFO == "B" {
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
return "0"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (y *Yaesu) SetPTT(on bool) error {
|
func (y *Yaesu) SetPTT(on bool) error {
|
||||||
@@ -396,18 +495,32 @@ func parseYaesuFreq(reply, prefix string) (int64, bool) {
|
|||||||
return hz, true
|
return hz, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// yaesuSplitOn reads the split reply for whichever command the rig answers.
|
// yaesuSplitFromReply turns the split reply into a yes or no.
|
||||||
//
|
//
|
||||||
// ST is a split flag: ST1 means split. FT names the TX VFO: FT1 means transmit
|
// The two commands say DIFFERENT things and reading them alike is a real fault,
|
||||||
// on VFO B, which IS split when the operator is listening on A. The two are not
|
// reported on an FTDX101 (F4NBZ, 2026-07-29) where the panel showed split ON with
|
||||||
// the same statement, which is why the command in use is remembered rather than
|
// the radio OFF and the reverse:
|
||||||
// both being tried and merged.
|
//
|
||||||
func yaesuSplitOn(reply, cmd string) bool {
|
// ST is a split FLAG — ST1 means split, whatever VFO is in use.
|
||||||
r := strings.TrimSpace(reply)
|
// FT names the TX VFO — FT0 = transmit on A, FT1 = transmit on B.
|
||||||
if !strings.HasPrefix(r, cmd) || len(r) < len(cmd)+1 {
|
//
|
||||||
|
// Split is on when the rig TRANSMITS on a different VFO from the one it is
|
||||||
|
// LISTENING to. Reading FT1 as "split" is therefore only right for an operator
|
||||||
|
// on VFO A: on SUB it is exactly inverted, which is why the same model behaved
|
||||||
|
// correctly for one operator and backwards for another — one was on MAIN, the
|
||||||
|
// other on SUB.
|
||||||
|
func yaesuSplitFromReply(reply, cmd, vfo string) bool {
|
||||||
|
d := yaesuStateDigit(reply, cmd)
|
||||||
|
if d == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return r[len(cmd)] == '1'
|
if cmd == "ST" {
|
||||||
|
return d == '1'
|
||||||
|
}
|
||||||
|
// FT: compare the transmit VFO with the one being listened to.
|
||||||
|
txOnB := d == '1'
|
||||||
|
rxOnB := strings.HasPrefix(strings.ToUpper(vfo), "B")
|
||||||
|
return txOnB != rxOnB
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveYaesuVFOs turns the two frequencies plus the VFO and split flags into
|
// resolveYaesuVFOs turns the two frequencies plus the VFO and split flags into
|
||||||
@@ -465,3 +578,25 @@ func yaesuModeDigit(mode string, freqHz int64) byte {
|
|||||||
return 'C' // DATA-USB
|
return 'C' // DATA-USB
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// yaesuStateDigit returns the STATE digit of a reply — the FIRST digit after
|
||||||
|
// the command — or 0 if the reply does not belong to this command.
|
||||||
|
//
|
||||||
|
// The parameter is not always one character: an FTDX101 answers "FR01;" where an
|
||||||
|
// FTDX10 answers "FR0;". The state is the FIRST digit in both; the second is a
|
||||||
|
// separate parameter. Reading the LAST digit inverted it — with RX and TX on
|
||||||
|
// MAIN the rig answered FR01, OpsLog concluded SUB, the main frequency stopped
|
||||||
|
// updating and a spot click tuned VFO B (F4NBZ, 2026-07-29). That was my own
|
||||||
|
// correction of the previous evening, made the wrong way round: the earlier
|
||||||
|
// "SUB shows MAIN" fault came from reading VS, not from this digit.
|
||||||
|
func yaesuStateDigit(reply, cmd string) byte {
|
||||||
|
r := strings.TrimSpace(reply)
|
||||||
|
if !strings.HasPrefix(r, cmd) || len(r) <= len(cmd) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
d := r[len(cmd)]
|
||||||
|
if d < '0' || d > '9' {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|||||||
@@ -397,12 +397,12 @@ func (y *Yaesu) SetYaesuSplit(on bool) error {
|
|||||||
return y.SetYaesuSplitOffset(y.defaultSplitOffset())
|
return y.SetYaesuSplitOffset(y.defaultSplitOffset())
|
||||||
}
|
}
|
||||||
y.mu.Lock()
|
y.mu.Lock()
|
||||||
cmd := y.splitCmd
|
cmd, vfo := y.splitCmd, y.curVFO
|
||||||
y.mu.Unlock()
|
y.mu.Unlock()
|
||||||
if cmd == "" {
|
if cmd == "" {
|
||||||
return fmt.Errorf("yaesu: this rig answered neither ST; nor FT; — split cannot be set")
|
return fmt.Errorf("yaesu: this rig answered neither ST; nor FT; — split cannot be set")
|
||||||
}
|
}
|
||||||
return y.setAndRefresh(fmt.Sprintf("%s0;", cmd))
|
return y.setAndRefresh(yaesuSplitCommand(cmd, vfo, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultSplitOffset is what SPLIT means on this mode: up 1 kHz on CW and the
|
// defaultSplitOffset is what SPLIT means on this mode: up 1 kHz on CW and the
|
||||||
@@ -430,7 +430,9 @@ func (y *Yaesu) SetYaesuModeRaw(name string) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("yaesu: unknown rig mode %q", name)
|
return fmt.Errorf("yaesu: unknown rig mode %q", name)
|
||||||
}
|
}
|
||||||
return y.setAndRefresh(fmt.Sprintf("MD0%c;", d))
|
// Same receiver as everywhere else — the console must not set main's mode
|
||||||
|
// while the operator is working the sub VFO.
|
||||||
|
return y.setAndRefresh(fmt.Sprintf("MD%s%c;", y.modeVFOSuffix(), d))
|
||||||
}
|
}
|
||||||
|
|
||||||
// yaesuRawModeDigit maps a rig-mode name to its MD digit.
|
// yaesuRawModeDigit maps a rig-mode name to its MD digit.
|
||||||
@@ -662,7 +664,7 @@ func (y *Yaesu) SetYaesuSplitOffset(offsetHz int64) error {
|
|||||||
return fmt.Errorf("yaesu: this rig answered neither ST; nor FT; — split cannot be set")
|
return fmt.Errorf("yaesu: this rig answered neither ST; nor FT; — split cannot be set")
|
||||||
}
|
}
|
||||||
time.Sleep(30 * time.Millisecond)
|
time.Sleep(30 * time.Millisecond)
|
||||||
if err := y.write(fmt.Sprintf("%s1;", y.splitCmd)); err != nil {
|
if err := y.write(yaesuSplitCommand(y.splitCmd, y.curVFO, true)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
y.panel.Split = true
|
y.panel.Split = true
|
||||||
@@ -808,3 +810,28 @@ func (m *meterPeak) update(sample int, now time.Time) int {
|
|||||||
}
|
}
|
||||||
return m.val
|
return m.val
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// yaesuSplitCommand builds the command that turns split on or off.
|
||||||
|
//
|
||||||
|
// Writing is as asymmetric as reading. ST takes the state directly — ST1 is
|
||||||
|
// split on. FT sets which VFO TRANSMITS, so "split on" means transmit on the
|
||||||
|
// OTHER VFO from the one being listened to, and "split off" means transmit on
|
||||||
|
// the same one. Sending FT1 for "on" regardless is right only for an operator on
|
||||||
|
// VFO A; on SUB it would clear the split it was asked to set.
|
||||||
|
func yaesuSplitCommand(cmd, vfo string, on bool) string {
|
||||||
|
if cmd == "ST" {
|
||||||
|
if on {
|
||||||
|
return "ST1;"
|
||||||
|
}
|
||||||
|
return "ST0;"
|
||||||
|
}
|
||||||
|
onSub := strings.HasPrefix(strings.ToUpper(vfo), "B")
|
||||||
|
txOnB := onSub // split off = transmit where we listen
|
||||||
|
if on {
|
||||||
|
txOnB = !onSub
|
||||||
|
}
|
||||||
|
if txOnB {
|
||||||
|
return cmd + "1;"
|
||||||
|
}
|
||||||
|
return cmd + "0;"
|
||||||
|
}
|
||||||
|
|||||||
+262
-12
@@ -54,24 +54,46 @@ func TestResolveYaesuVFOs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ST and FT say different things and must not be read the same way — see the
|
// ST and FT say different things, and reading them alike inverted the split
|
||||||
// comment on yaesuSplitOn.
|
// display on an FTDX101 (F4NBZ, 2026-07-29): the panel showed split ON with the
|
||||||
|
// radio OFF and the reverse.
|
||||||
|
//
|
||||||
|
// ST is a split FLAG — ST1 means split, whatever VFO is in use.
|
||||||
|
// FT names the TX VFO — FT0 = transmit on A, FT1 = transmit on B.
|
||||||
|
//
|
||||||
|
// Split is on when the rig transmits on a DIFFERENT VFO from the one it listens
|
||||||
|
// to, so FT has to be compared with the current VFO. That is why the same model
|
||||||
|
// behaved correctly for one operator and backwards for another: one was on MAIN,
|
||||||
|
// the other on SUB.
|
||||||
func TestYaesuSplitReply(t *testing.T) {
|
func TestYaesuSplitReply(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
reply, cmd string
|
reply, cmd, vfo string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{"ST1;", "ST", true},
|
// The flag is absolute.
|
||||||
{"ST0;", "ST", false},
|
{"ST1;", "ST", "A", true},
|
||||||
{"FT1;", "FT", true},
|
{"ST0;", "ST", "A", false},
|
||||||
{"FT0;", "FT", false},
|
{"ST1;", "ST", "B", true},
|
||||||
{"ST1;", "FT", false}, // reply for the other command — not accepted
|
{"ST0;", "ST", "B", false},
|
||||||
{"ST", "ST", false}, // truncated
|
|
||||||
{"", "ST", false},
|
// Listening on A: transmit on B is split, transmit on A is not.
|
||||||
|
{"FT1;", "FT", "A", true},
|
||||||
|
{"FT0;", "FT", "A", false},
|
||||||
|
// Listening on B: exactly the opposite — the reported inversion.
|
||||||
|
{"FT0;", "FT", "B", true},
|
||||||
|
{"FT1;", "FT", "B", false},
|
||||||
|
// The pair enums the Yaesus also report start with the listening VFO.
|
||||||
|
{"FT0;", "FT", "BA", true},
|
||||||
|
{"FT1;", "FT", "AB", true},
|
||||||
|
|
||||||
|
{"ST1;", "FT", "A", false}, // reply for the other command — not accepted
|
||||||
|
{"ST", "ST", "A", false}, // truncated
|
||||||
|
{"", "ST", "A", false},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
if got := yaesuSplitOn(c.reply, c.cmd); got != c.want {
|
if got := yaesuSplitFromReply(c.reply, c.cmd, c.vfo); got != c.want {
|
||||||
t.Errorf("yaesuSplitOn(%q,%q) = %v, want %v", c.reply, c.cmd, got, c.want)
|
t.Errorf("yaesuSplitFromReply(%q, %q, vfo=%q) = %v, want %v",
|
||||||
|
c.reply, c.cmd, c.vfo, got, c.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,3 +152,231 @@ func TestYaesuCmdPrefix(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Writing split is as asymmetric as reading it.
|
||||||
|
//
|
||||||
|
// ST takes the state directly. FT sets which VFO TRANSMITS, so "split on" means
|
||||||
|
// transmit on the OTHER VFO from the one being listened to. Sending FT1 for "on"
|
||||||
|
// regardless is right only on VFO A — on SUB it would CLEAR the split it was
|
||||||
|
// asked to set, which is the same inversion that showed on the FTDX101 panel.
|
||||||
|
func TestYaesuSplitCommand(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
cmd, vfo string
|
||||||
|
on bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"ST", "A", true, "ST1;"},
|
||||||
|
{"ST", "B", true, "ST1;"}, // the flag does not care which VFO
|
||||||
|
{"ST", "B", false, "ST0;"},
|
||||||
|
|
||||||
|
// Listening on A: split means transmit on B.
|
||||||
|
{"FT", "A", true, "FT1;"},
|
||||||
|
{"FT", "A", false, "FT0;"},
|
||||||
|
// Listening on B: split means transmit on A — the reverse.
|
||||||
|
{"FT", "B", true, "FT0;"},
|
||||||
|
{"FT", "B", false, "FT1;"},
|
||||||
|
// Pair enums start with the listening VFO.
|
||||||
|
{"FT", "BA", true, "FT0;"},
|
||||||
|
{"FT", "AB", true, "FT1;"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := yaesuSplitCommand(c.cmd, c.vfo, c.on); got != c.want {
|
||||||
|
t.Errorf("yaesuSplitCommand(%q, vfo=%q, on=%v) = %q, want %q",
|
||||||
|
c.cmd, c.vfo, c.on, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which VFO the operator is listening on, on a rig with separate RX and TX
|
||||||
|
// selection.
|
||||||
|
//
|
||||||
|
// Reported on an FTDX101 (2026-07-29): moving RX alone to SUB displayed VFO B
|
||||||
|
// correctly, but moving BOTH RX and TX to SUB displayed VFO A — the operator was
|
||||||
|
// entirely on B and OpsLog showed the other one. FR reports the receive VFO; VS
|
||||||
|
// does not answer that question on this rig.
|
||||||
|
//
|
||||||
|
// With FR read correctly the split follows too, since split is "transmit VFO
|
||||||
|
// differs from receive VFO".
|
||||||
|
func TestYaesuReceiveVFOAndSplit(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
fr, ft string // replies
|
||||||
|
wantVFO string
|
||||||
|
wantSplit bool
|
||||||
|
}{
|
||||||
|
{"everything on main", "FR0;", "FT0;", "A", false},
|
||||||
|
{"RX on sub, TX still on main — split", "FR1;", "FT0;", "B", true},
|
||||||
|
{"RX and TX both on sub — NOT split", "FR1;", "FT1;", "B", false},
|
||||||
|
{"RX on main, TX on sub — split", "FR0;", "FT1;", "A", true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
vfo := "A"
|
||||||
|
if len(c.fr) >= 3 && c.fr[2] == '1' {
|
||||||
|
vfo = "B"
|
||||||
|
}
|
||||||
|
if vfo != c.wantVFO {
|
||||||
|
t.Errorf("%s: receive VFO = %s, want %s", c.name, vfo, c.wantVFO)
|
||||||
|
}
|
||||||
|
if got := yaesuSplitFromReply(c.ft, "FT", vfo); got != c.wantSplit {
|
||||||
|
t.Errorf("%s: split = %v, want %v", c.name, got, c.wantSplit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The state digit of a reply, when the parameter is not one character.
|
||||||
|
//
|
||||||
|
// An FTDX101 answers "FR01;" where an FTDX10 answers "FR0;". The state is the
|
||||||
|
// FIRST digit on both — the second is a separate parameter.
|
||||||
|
//
|
||||||
|
// This test asserted the opposite for one evening. Reading the LAST digit turned
|
||||||
|
// "FR01" into SUB, so an operator with RX and TX on MAIN saw the main frequency
|
||||||
|
// freeze and a spot click tune VFO B (F4NBZ, 2026-07-29). The fault it was meant
|
||||||
|
// to fix — "SUB shows MAIN" — came from reading VS, not from this digit, and the
|
||||||
|
// FR probe alone had already fixed it.
|
||||||
|
func TestYaesuStateDigit(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
reply, cmd string
|
||||||
|
want byte
|
||||||
|
}{
|
||||||
|
{"FR0;", "FR", '0'}, // FTDX10 form
|
||||||
|
{"FR1;", "FR", '1'},
|
||||||
|
{"FR01;", "FR", '0'}, // FTDX101 form: MAIN — the reported bug read this as SUB
|
||||||
|
{"FR11;", "FR", '1'}, // …and this is SUB
|
||||||
|
{"ST1;", "ST", '1'},
|
||||||
|
{"FT0;", "FT", '0'},
|
||||||
|
{"VS1;", "VS", '1'},
|
||||||
|
{"FR1", "FR", '1'}, // terminator already stripped
|
||||||
|
|
||||||
|
{"ST1;", "FR", 0}, // another command's reply is never accepted
|
||||||
|
{"FR;", "FR", 0}, // query echoed with no value
|
||||||
|
{"FRx;", "FR", 0}, // not a digit
|
||||||
|
{"", "FR", 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := yaesuStateDigit(c.reply, c.cmd); got != c.want {
|
||||||
|
t.Errorf("yaesuStateDigit(%q, %q) = %q, want %q", c.reply, c.cmd, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End to end, both directions of the reported fault:
|
||||||
|
if d := yaesuStateDigit("FR01;", "FR"); d != '0' {
|
||||||
|
t.Fatalf("FR01 read as %q — the operator is on MAIN and must be seen there", d)
|
||||||
|
}
|
||||||
|
if d := yaesuStateDigit("FR11;", "FR"); d != '1' {
|
||||||
|
t.Fatalf("FR11 read as %q — the operator is on SUB", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A spot click tunes the VFO the operator is ON, and the display reads that same
|
||||||
|
// VFO. Both follow from the receive-VFO digit, which is why it is pinned here in
|
||||||
|
// the operator's terms rather than only as a byte.
|
||||||
|
//
|
||||||
|
// Reported both ways round on an FTDX101 (F4NBZ, 2026-07-29): with RX and TX on
|
||||||
|
// SUB everything worked, and with them on MAIN the frequency froze and a spot
|
||||||
|
// clicked tuned the sub VFO.
|
||||||
|
func TestYaesuActiveVFOFollowsReceiveVFO(t *testing.T) {
|
||||||
|
// Mirrors ReadState's choice of VFO and SetFrequency's choice of command.
|
||||||
|
activeVFO := func(frReply string) string {
|
||||||
|
if yaesuStateDigit(frReply, "FR") == '1' {
|
||||||
|
return "B"
|
||||||
|
}
|
||||||
|
return "A"
|
||||||
|
}
|
||||||
|
tuneCmd := func(vfo string) string {
|
||||||
|
if vfo == "B" {
|
||||||
|
return "FB" // sub
|
||||||
|
}
|
||||||
|
return "FA" // main
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name, fr, wantVFO, wantCmd string
|
||||||
|
}{
|
||||||
|
{"RX and TX on MAIN (FTDX101 two-digit reply)", "FR01;", "A", "FA"},
|
||||||
|
{"RX and TX on SUB (FTDX101)", "FR11;", "B", "FB"},
|
||||||
|
{"MAIN on a one-digit rig", "FR0;", "A", "FA"},
|
||||||
|
{"SUB on a one-digit rig", "FR1;", "B", "FB"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
vfo := activeVFO(c.fr)
|
||||||
|
if vfo != c.wantVFO {
|
||||||
|
t.Errorf("%s: active VFO = %s, want %s", c.name, vfo, c.wantVFO)
|
||||||
|
}
|
||||||
|
if cmd := tuneCmd(vfo); cmd != c.wantCmd {
|
||||||
|
t.Errorf("%s: a spot click would write %s, want %s", c.name, cmd, c.wantCmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which split command to ASK, given what else the rig answers.
|
||||||
|
//
|
||||||
|
// ST is a bare flag and its meaning varies: an FTDX101 with RX and TX both on
|
||||||
|
// SUB reports ST1, which is plain simplex on the sub VFO, and OpsLog showed
|
||||||
|
// split with the main frequency as the transmit one (F4NBZ, 2026-07-29).
|
||||||
|
//
|
||||||
|
// FT names the transmit VFO. Where the receive VFO is known too (FR), split is
|
||||||
|
// derived from the pair — they differ or they do not — which is a fact about the
|
||||||
|
// rig rather than a flag to be interpreted. So FT is preferred when FR answered.
|
||||||
|
func TestYaesuSplitProbeOrder(t *testing.T) {
|
||||||
|
order := func(rxVFOCmd string) []string {
|
||||||
|
if rxVFOCmd != "" {
|
||||||
|
return []string{"FT", "ST"}
|
||||||
|
}
|
||||||
|
return []string{"ST", "FT"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := order("FR")[0]; got != "FT" {
|
||||||
|
t.Errorf("with FR available the first split probe is %q, want FT", got)
|
||||||
|
}
|
||||||
|
if got := order("")[0]; got != "ST" {
|
||||||
|
t.Errorf("without FR the first split probe is %q, want ST", got)
|
||||||
|
}
|
||||||
|
// Both remain available: a rig answering only one must still be handled.
|
||||||
|
for _, rx := range []string{"FR", ""} {
|
||||||
|
if len(order(rx)) != 2 {
|
||||||
|
t.Errorf("rxVFOCmd=%q: both probes must remain, got %v", rx, order(rx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The case that was reported, end to end: RX and TX both on sub is NOT split.
|
||||||
|
if yaesuSplitFromReply("FT1;", "FT", "B") {
|
||||||
|
t.Error("RX and TX both on SUB reported as split")
|
||||||
|
}
|
||||||
|
// And the flag alone would have got it wrong, which is why the order changed.
|
||||||
|
if !yaesuSplitFromReply("ST1;", "ST", "B") {
|
||||||
|
t.Error("ST1 is a flag and reads as split whatever the VFO — that is the trap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mode commands address the receiver the operator is ON.
|
||||||
|
//
|
||||||
|
// Reported on an FTDX101 (F4NBZ, 2026-07-29): once a spot click tuned the right
|
||||||
|
// VFO, it still changed the mode of MAIN while the operator worked from SUB — so
|
||||||
|
// the VFO in use kept its old mode and the idle one was altered. The read had the
|
||||||
|
// same fault, so the panel showed main's mode as well.
|
||||||
|
func TestYaesuModeVFOSuffix(t *testing.T) {
|
||||||
|
cases := []struct{ vfo, want string }{
|
||||||
|
{"A", "0"}, // main
|
||||||
|
{"", "0"}, // not yet read — main is the safe assumption
|
||||||
|
{"B", "1"}, // sub
|
||||||
|
{"AB", "0"}, // pair enums start with the receive VFO
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
y := &Yaesu{}
|
||||||
|
y.curVFO = c.vfo
|
||||||
|
if got := y.modeVFOSuffix(); got != c.want {
|
||||||
|
t.Errorf("curVFO=%q → MD%s, want MD%s", c.vfo, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spelled out as the commands actually sent, which is what the rig sees.
|
||||||
|
y := &Yaesu{}
|
||||||
|
y.curVFO = "B"
|
||||||
|
if cmd := "MD" + y.modeVFOSuffix() + "3;"; cmd != "MD13;" {
|
||||||
|
t.Errorf("setting CW on the sub receiver sends %q, want MD13;", cmd)
|
||||||
|
}
|
||||||
|
y.curVFO = "A"
|
||||||
|
if cmd := "MD" + y.modeVFOSuffix() + "3;"; cmd != "MD03;" {
|
||||||
|
t.Errorf("setting CW on main sends %q, want MD03;", cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ package cluster
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"regexp"
|
"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.
|
// runOnce dials, optionally logs in, sends init commands, parses spots.
|
||||||
// Returns the moment we marked the link "connected" (zero if dial failed)
|
// Returns the moment we marked the link "connected" (zero if dial failed)
|
||||||
// and the error that ended the session (nil if stopCh).
|
// and the error that ended the session (nil if stopCh).
|
||||||
func (s *session) runOnce() (time.Time, error) {
|
func (s *session) runOnce() (time.Time, error) {
|
||||||
addr := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port)) // IPv6-safe
|
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 {
|
if err != nil {
|
||||||
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
|
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 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)
|
rd := bufio.NewReader(conn)
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -431,11 +451,37 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(120 * time.Second))
|
// The deadline exists to keep this loop responsive to stopCh, NOT to
|
||||||
line, err := rd.ReadString('\n')
|
// 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 {
|
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)
|
return connectedAt, fmt.Errorf("read: %w", err)
|
||||||
}
|
}
|
||||||
|
quiet = 0
|
||||||
|
line := pending + chunk
|
||||||
|
pending = ""
|
||||||
line = strings.TrimRight(line, "\r\n")
|
line = strings.TrimRight(line, "\r\n")
|
||||||
// Strip terminal BELLs (\a) and other control bytes that some nodes
|
// 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
|
// (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
|
pendHops int // consecutive hops the raw state has disagreed
|
||||||
|
|
||||||
// Two-cluster element timing (ms).
|
// 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
|
muDit, muDah float64
|
||||||
marksSeen int
|
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)
|
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)
|
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
|
// 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.stableHops, d.pendHops = 0, 0
|
||||||
d.bankTick, d.betterHops = 0, 0
|
d.bankTick, d.betterHops = 0, 0
|
||||||
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
|
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
|
||||||
|
d.muLetterGap = 0
|
||||||
d.elemMs = d.elemMs[:0]
|
d.elemMs = d.elemMs[:0]
|
||||||
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
|
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).
|
// steadier than dits in hand keying).
|
||||||
func (d *Decoder) endSpace(hops int) {
|
func (d *Decoder) endSpace(hops int) {
|
||||||
ms := float64(hops)*d.hopMs + d.biasMs // gaps shrink by what marks gained
|
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 {
|
if d.marksSeen < 1 || ms < 0.35*d.muDit || ms > 1.7*d.muDit {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -592,6 +634,40 @@ func (d *Decoder) endSpace(hops int) {
|
|||||||
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
|
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
|
// spaceProgress emits the pending character / word space LIVE once the current
|
||||||
// gap crosses each boundary (instead of waiting for the next mark), so text
|
// gap crosses each boundary (instead of waiting for the next mark), so text
|
||||||
// appears as it is sent.
|
// appears as it is sent.
|
||||||
@@ -601,7 +677,7 @@ func (d *Decoder) spaceProgress() {
|
|||||||
d.flushChar()
|
d.flushChar()
|
||||||
d.charEmitted = true
|
d.charEmitted = true
|
||||||
}
|
}
|
||||||
if !d.wordEmitted && gapMs > wordGapDits*d.muDit {
|
if !d.wordEmitted && gapMs > d.wordGapMs() {
|
||||||
if d.textSince && d.onChar != nil {
|
if d.textSince && d.onChar != nil {
|
||||||
d.onChar(" ")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
-13
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -35,7 +36,13 @@ import (
|
|||||||
var (
|
var (
|
||||||
reStrftimeDefault = regexp.MustCompile(`\(strftime\('%Y-%m-%dT%H:%M:%fZ',\s*'now'\)\)`)
|
reStrftimeDefault = regexp.MustCompile(`\(strftime\('%Y-%m-%dT%H:%M:%fZ',\s*'now'\)\)`)
|
||||||
reBareInteger = regexp.MustCompile(`\bINTEGER\b`)
|
reBareInteger = regexp.MustCompile(`\bINTEGER\b`)
|
||||||
reColText = regexp.MustCompile(`(\w+)\s+TEXT\b`)
|
// The column name may be bare or quoted. The SQLite baseline dump quotes its
|
||||||
|
// identifiers, and those double quotes are already BACKTICKS by the time this
|
||||||
|
// runs — so a pattern matching only a bare name left every column in the
|
||||||
|
// baseline as TEXT. That produced both "BLOB/TEXT column 'state' used in key
|
||||||
|
// specification without a key length" and "column 'op_name' can't have a
|
||||||
|
// default value" on a MySQL logbook, neither of which names the real cause.
|
||||||
|
reColText = regexp.MustCompile("(`?)(\\w+)(`?)\\s+TEXT\\b")
|
||||||
reKeyColumn = regexp.MustCompile(`(?m)^(\s*)key\b`)
|
reKeyColumn = regexp.MustCompile(`(?m)^(\s*)key\b`)
|
||||||
// SQLite quotes identifiers with double quotes — e.g. a table renamed by
|
// SQLite quotes identifiers with double quotes — e.g. a table renamed by
|
||||||
// ALTER … RENAME TO ends up as CREATE TABLE "name" in sqlite_master. MySQL
|
// ALTER … RENAME TO ends up as CREATE TABLE "name" in sqlite_master. MySQL
|
||||||
@@ -103,27 +110,59 @@ func mysqlDDL(stmt string) string {
|
|||||||
// right MySQL type, deciding per line so it can see whether the line carries a
|
// right MySQL type, deciding per line so it can see whether the line carries a
|
||||||
// DEFAULT or an inline PRIMARY KEY. See varcharColumns / longTextColumns.
|
// DEFAULT or an inline PRIMARY KEY. See varcharColumns / longTextColumns.
|
||||||
func translateTextColumns(s string) string {
|
func translateTextColumns(s string) string {
|
||||||
lines := strings.Split(s, "\n")
|
// EVERY column declaration is handled, wherever it sits.
|
||||||
for i, line := range lines {
|
//
|
||||||
m := reColText.FindStringSubmatchIndex(line)
|
// This used to work line by line and convert only the FIRST column on each
|
||||||
if m == nil {
|
// line. That is fine for our hand-written migrations, which put one column
|
||||||
continue
|
// per line, and wrong for the SQLite baseline: sqlite_master stores a table's
|
||||||
}
|
// CREATE statement with every ALTER-added column appended to the SAME line,
|
||||||
col := line[m[2]:m[3]]
|
// so on a real logbook only one of them was converted and MySQL rejected the
|
||||||
|
// rest — "column 'op_name' can't have a default value".
|
||||||
|
//
|
||||||
|
// Each match is also decided from ITS OWN declaration — the text between the
|
||||||
|
// surrounding commas — rather than from the whole line, so a DEFAULT
|
||||||
|
// belonging to a neighbouring column cannot decide this one's type.
|
||||||
|
out := make([]byte, 0, len(s)+64)
|
||||||
|
last := 0
|
||||||
|
for _, m := range reColText.FindAllStringSubmatchIndex(s, -1) {
|
||||||
|
openQ, col, closeQ := s[m[2]:m[3]], s[m[4]:m[5]], s[m[6]:m[7]]
|
||||||
|
decl := declarationAround(s, m[0], m[1])
|
||||||
var typ string
|
var typ string
|
||||||
switch {
|
switch {
|
||||||
case longTextColumns[col] != "":
|
case longTextColumns[col] != "":
|
||||||
typ = longTextColumns[col]
|
typ = longTextColumns[col]
|
||||||
case varcharColumns[col],
|
case varcharColumns[col],
|
||||||
strings.Contains(line, "DEFAULT"),
|
strings.Contains(decl, "DEFAULT"),
|
||||||
strings.Contains(line, "PRIMARY KEY"):
|
strings.Contains(decl, "PRIMARY KEY"):
|
||||||
typ = "VARCHAR(255)"
|
typ = "VARCHAR(255)"
|
||||||
default:
|
default:
|
||||||
typ = "TEXT"
|
typ = "TEXT"
|
||||||
}
|
}
|
||||||
lines[i] = line[:m[0]] + col + " " + typ + line[m[1]:]
|
out = append(out, s[last:m[0]]...)
|
||||||
|
out = append(out, (openQ + col + closeQ + " " + typ)...)
|
||||||
|
last = m[1]
|
||||||
}
|
}
|
||||||
return strings.Join(lines, "\n")
|
return string(append(out, s[last:]...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// declarationAround returns the single column declaration containing [start,end)
|
||||||
|
// — the text between the commas, brackets or newlines either side.
|
||||||
|
//
|
||||||
|
// Types are decided from this rather than from the whole line because several
|
||||||
|
// declarations share a line in the SQLite baseline, and a neighbour's DEFAULT
|
||||||
|
// must not decide our column's type.
|
||||||
|
func declarationAround(s string, start, end int) string {
|
||||||
|
from := strings.LastIndexAny(s[:start], ",(\n")
|
||||||
|
if from < 0 {
|
||||||
|
from = 0
|
||||||
|
}
|
||||||
|
to := strings.IndexAny(s[end:], ",)\n")
|
||||||
|
if to < 0 {
|
||||||
|
to = len(s)
|
||||||
|
} else {
|
||||||
|
to += end
|
||||||
|
}
|
||||||
|
return s[from:to]
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenMySQL opens the shared MySQL logbook, creating the database if needed,
|
// OpenMySQL opens the shared MySQL logbook, creating the database if needed,
|
||||||
@@ -200,7 +239,15 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
|||||||
// especially on a server with slow DDL.
|
// especially on a server with slow DDL.
|
||||||
err = applyMySQLBaseline(conn)
|
err = applyMySQLBaseline(conn)
|
||||||
} else {
|
} else {
|
||||||
// Existing database: apply only the migrations it's missing.
|
// Existing database: repair any column that must be VARCHAR before the
|
||||||
|
// migrations run — an index on a TEXT column fails, and that failure
|
||||||
|
// otherwise repeats at every start with no way out from the UI.
|
||||||
|
if rerr := repairMySQLTextColumns(context.Background(), conn); rerr != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
Dialect = "sqlite"
|
||||||
|
return nil, rerr
|
||||||
|
}
|
||||||
|
// Then apply only the migrations it's missing.
|
||||||
err = migrate(conn, mysqlDDL, "", "mysql:"+name)
|
err = migrate(conn, mysqlDDL, "", "mysql:"+name)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -534,3 +581,55 @@ func isIgnorableDDLError(err error) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// repairMySQLTextColumns converts columns that MUST be VARCHAR but are TEXT.
|
||||||
|
//
|
||||||
|
// Translating the schema correctly does not help a database that already exists.
|
||||||
|
// A logbook created by an earlier version — or left half-built by a migration
|
||||||
|
// that failed partway — keeps its TEXT columns, and every later run dies on the
|
||||||
|
// same statement: "CREATE INDEX … ON qso(state)" → 1170, because MySQL cannot
|
||||||
|
// index a TEXT column without a prefix length. The operator sees the connection
|
||||||
|
// fail at startup, for ever, with no way forward from the UI.
|
||||||
|
//
|
||||||
|
// So the schema is repaired before the migrations run. Only the columns in
|
||||||
|
// varcharColumns are touched — the ones that are indexed or part of a key — and
|
||||||
|
// only when they are actually TEXT, so this is a no-op on a healthy database.
|
||||||
|
func repairMySQLTextColumns(ctx context.Context, conn *sql.DB) error {
|
||||||
|
rows, err := conn.QueryContext(ctx, `
|
||||||
|
SELECT TABLE_NAME, COLUMN_NAME, IS_NULLABLE
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND DATA_TYPE IN ('text','mediumtext','longtext')`)
|
||||||
|
if err != nil {
|
||||||
|
// Not fatal: a server that will not answer information_schema can still
|
||||||
|
// run a healthy schema, and failing here would block a working logbook.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
type col struct{ table, name, nullable string }
|
||||||
|
var todo []col
|
||||||
|
for rows.Next() {
|
||||||
|
var c col
|
||||||
|
if err := rows.Scan(&c.table, &c.name, &c.nullable); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if varcharColumns[c.name] {
|
||||||
|
todo = append(todo, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
for _, c := range todo {
|
||||||
|
null := "NULL"
|
||||||
|
if c.nullable == "NO" {
|
||||||
|
null = "NOT NULL"
|
||||||
|
}
|
||||||
|
stmt := fmt.Sprintf("ALTER TABLE `%s` MODIFY `%s` VARCHAR(255) %s", c.table, c.name, null)
|
||||||
|
if _, err := conn.ExecContext(ctx, stmt); err != nil {
|
||||||
|
// Report it: this is the difference between a logbook that opens and
|
||||||
|
// one that does not, so a failure here must not be silent.
|
||||||
|
return fmt.Errorf("repair %s.%s to VARCHAR: %w", c.table, c.name, err)
|
||||||
|
}
|
||||||
|
log.Printf("db[mysql]: repaired %s.%s TEXT → VARCHAR(255) (it is indexed)", c.table, c.name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -151,3 +151,102 @@ func TestMySQLDDL_NoLeftoverSQLiteisms(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A QUOTED column declaration must be translated like a bare one.
|
||||||
|
//
|
||||||
|
// The SQLite baseline dump quotes its identifiers, and mysqlDDL turns those
|
||||||
|
// double quotes into backticks before the TEXT rules run. The pattern only
|
||||||
|
// matched a bare name, so every column in the baseline stayed TEXT — and MySQL
|
||||||
|
// then refused the schema in two different ways, neither naming the real cause:
|
||||||
|
//
|
||||||
|
// CREATE INDEX … ON qso(state) → 1170 BLOB/TEXT column used in key
|
||||||
|
// specification without a key length
|
||||||
|
// op_name TEXT … DEFAULT '' → 1101 TEXT column can't have a default value
|
||||||
|
//
|
||||||
|
// The operator saw only "MySQL is enabled but the connection failed at startup",
|
||||||
|
// and fell back to the local SQLite database.
|
||||||
|
func TestMySQLDDL_QuotedTextColumns(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, in, wantHas string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// An indexed column must become VARCHAR, quoted or not.
|
||||||
|
name: "backticked indexed column",
|
||||||
|
in: "CREATE TABLE qso (`state` TEXT);",
|
||||||
|
wantHas: "`state` VARCHAR(255)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare indexed column still works",
|
||||||
|
in: "ALTER TABLE qso ADD COLUMN state TEXT;",
|
||||||
|
wantHas: "state VARCHAR(255)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A default-bearing column must become VARCHAR, or MySQL rejects it.
|
||||||
|
name: "double-quoted column with a default",
|
||||||
|
in: `CREATE TABLE station_profiles ("op_name" TEXT NOT NULL DEFAULT '');`,
|
||||||
|
wantHas: "`op_name` VARCHAR(255)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// And a plain unindexed column stays TEXT: VARCHAR everywhere would
|
||||||
|
// blow past MySQL's row-size limit, which is why the rule exists.
|
||||||
|
name: "unindexed column stays TEXT",
|
||||||
|
in: "CREATE TABLE qso (`comment` TEXT);",
|
||||||
|
wantHas: "`comment` TEXT",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := mysqlDDL(c.in)
|
||||||
|
if !strings.Contains(got, c.wantHas) {
|
||||||
|
t.Errorf("%s:\n in %s\n got %s\n want it to contain %q", c.name, c.in, got, c.wantHas)
|
||||||
|
}
|
||||||
|
// The quoting must survive: a lost backtick is a syntax error further on.
|
||||||
|
if strings.Count(got, "`")%2 != 0 {
|
||||||
|
t.Errorf("%s: unbalanced backticks in %s", c.name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A CREATE TABLE with several columns on ONE line — the shape sqlite_master
|
||||||
|
// stores once columns have been added by ALTER TABLE.
|
||||||
|
//
|
||||||
|
// The old translation converted only the first column per line, so on a real
|
||||||
|
// logbook the baseline kept most of its columns as TEXT and MySQL refused the
|
||||||
|
// schema: "column 'op_name' can't have a default value". Our own migration files
|
||||||
|
// hide this — they put one column per line — which is why it only ever appeared
|
||||||
|
// against a database that had been through upgrades.
|
||||||
|
func TestMySQLDDL_ManyColumnsOnOneLine(t *testing.T) {
|
||||||
|
in := "CREATE TABLE station_profiles (id INTEGER PRIMARY KEY, `name` TEXT, " +
|
||||||
|
"`op_name` TEXT NOT NULL DEFAULT '', `comment` TEXT, `state` TEXT);"
|
||||||
|
got := mysqlDDL(in)
|
||||||
|
|
||||||
|
// Default-bearing and indexed columns must be VARCHAR…
|
||||||
|
for _, want := range []string{"`op_name` VARCHAR(255)", "`state` VARCHAR(255)"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("missing %q in:\n %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// …while the plain ones stay TEXT, or the row-size limit is breached.
|
||||||
|
for _, want := range []string{"`name` TEXT", "`comment` TEXT"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("missing %q in:\n %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No column may be left as a bare TEXT that carries a default.
|
||||||
|
if strings.Contains(got, "TEXT NOT NULL DEFAULT") {
|
||||||
|
t.Errorf("a TEXT column still carries a DEFAULT — MySQL rejects that:\n %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A neighbour's DEFAULT must not decide this column's type: deciding per LINE
|
||||||
|
// made every column on a shared line VARCHAR as soon as one of them had a
|
||||||
|
// default, which is how the row-size limit gets breached quietly.
|
||||||
|
func TestMySQLDDL_DefaultDoesNotLeakAcrossColumns(t *testing.T) {
|
||||||
|
in := "CREATE TABLE t (`a` TEXT NOT NULL DEFAULT '', `b` TEXT);"
|
||||||
|
got := mysqlDDL(in)
|
||||||
|
if !strings.Contains(got, "`a` VARCHAR(255)") {
|
||||||
|
t.Errorf("the column WITH the default should be VARCHAR:\n %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "`b` TEXT") {
|
||||||
|
t.Errorf("the column without one should stay TEXT:\n %s", 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"`
|
HRDLog ServiceConfig `json:"hrdlog"`
|
||||||
EQSL ServiceConfig `json:"eqsl"`
|
EQSL ServiceConfig `json:"eqsl"`
|
||||||
Cloudlog ServiceConfig `json:"cloudlog"`
|
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.
|
// UploadResult is the outcome of a single upload attempt.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -14,6 +16,14 @@ import (
|
|||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// remoteFreqRe / remoteModeRe pull the optional tune request out of a
|
||||||
|
// ServiceRemoteCall packet: "<FREQ>10.136" (MHz) and "<MODE>FT8". Both accept
|
||||||
|
// an optional closing tag for proper-XML senders.
|
||||||
|
var (
|
||||||
|
remoteFreqRe = regexp.MustCompile(`(?i)<FREQ>\s*([0-9]+(?:\.[0-9]+)?)`)
|
||||||
|
remoteModeRe = regexp.MustCompile(`(?i)<MODE>\s*([A-Z0-9-]+)`)
|
||||||
|
)
|
||||||
|
|
||||||
// reusingListenConfig builds a net.ListenConfig that sets SO_REUSEADDR
|
// reusingListenConfig builds a net.ListenConfig that sets SO_REUSEADDR
|
||||||
// (and SO_REUSEPORT on Unix) on the underlying socket before bind. This
|
// (and SO_REUSEPORT on Unix) on the underlying socket before bind. This
|
||||||
// is the only way for two processes to share a UDP port on Windows — Go
|
// is the only way for two processes to share a UDP port on Windows — Go
|
||||||
@@ -57,6 +67,13 @@ type Event struct {
|
|||||||
// Call after previously reporting one — i.e. the operator cleared the call in
|
// Call after previously reporting one — i.e. the operator cleared the call in
|
||||||
// the digital app. OpsLog clears its entry to match.
|
// the digital app. OpsLog clears its entry to match.
|
||||||
ClearCall bool
|
ClearCall bool
|
||||||
|
|
||||||
|
// TuneFreqHz / TuneMode carry an explicit "tune the radio" request embedded
|
||||||
|
// in a ServiceRemoteCall packet (DXHunter spot click sends
|
||||||
|
// "<CALLSIGN>VP5G<FREQ>10.136<MODE>FT8"). Zero/empty = no tune requested —
|
||||||
|
// the packet only fills the entry callsign, exactly as before.
|
||||||
|
TuneFreqHz int64
|
||||||
|
TuneMode string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is a single inbound UDP listener.
|
// Server is a single inbound UDP listener.
|
||||||
@@ -248,9 +265,25 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
// "CALL F4XYZ" (text prefix)
|
// "CALL F4XYZ" (text prefix)
|
||||||
// "<CALLSIGN>F4XYZ<CALLSIGN>" (DXHunter-style tags)
|
// "<CALLSIGN>F4XYZ<CALLSIGN>" (DXHunter-style tags)
|
||||||
// "<CALLSIGN>F4XYZ</CALLSIGN>" (proper XML)
|
// "<CALLSIGN>F4XYZ</CALLSIGN>" (proper XML)
|
||||||
|
// "<CALLSIGN>VP5G<FREQ>10.136<MODE>FT8" (DXHunter with CAT tune)
|
||||||
// Strip every angle-bracket tag, normalise whitespace, take the
|
// Strip every angle-bracket tag, normalise whitespace, take the
|
||||||
// last non-empty token. Upper-case for downstream consistency.
|
// last non-empty token. Upper-case for downstream consistency.
|
||||||
text := string(pkt)
|
text := string(pkt)
|
||||||
|
// Optional tune request: <FREQ>MHz and <MODE>str ride along with the
|
||||||
|
// callsign so a DXHunter spot click can drive OpsLog's CAT. Extract
|
||||||
|
// (and cut) them BEFORE the generic tag-stripping below, which would
|
||||||
|
// otherwise leave their values as stray tokens and corrupt the
|
||||||
|
// "last token = callsign" heuristic.
|
||||||
|
if m := remoteFreqRe.FindStringSubmatch(text); m != nil {
|
||||||
|
if mhz, err := strconv.ParseFloat(m[1], 64); err == nil && mhz > 0 {
|
||||||
|
ev.TuneFreqHz = int64(mhz * 1e6)
|
||||||
|
}
|
||||||
|
text = strings.Replace(text, m[0], " ", 1)
|
||||||
|
}
|
||||||
|
if m := remoteModeRe.FindStringSubmatch(text); m != nil {
|
||||||
|
ev.TuneMode = strings.ToUpper(m[1])
|
||||||
|
text = strings.Replace(text, m[0], " ", 1)
|
||||||
|
}
|
||||||
// Drop every <...> tag (open or close) — works for both
|
// Drop every <...> tag (open or close) — works for both
|
||||||
// <CALLSIGN>...<CALLSIGN> and <CALLSIGN>...</CALLSIGN>.
|
// <CALLSIGN>...<CALLSIGN> and <CALLSIGN>...</CALLSIGN>.
|
||||||
for {
|
for {
|
||||||
@@ -286,8 +319,9 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Empty events are useless; skip — EXCEPT a clear signal, which is meant to be
|
// Empty events are useless; skip — EXCEPT a clear signal, which is meant to be
|
||||||
// empty (the DX Call was cleared in the digital app).
|
// empty (the DX Call was cleared in the digital app), and a tune-only
|
||||||
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && !ev.ClearCall {
|
// request (freq with no callsign).
|
||||||
|
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && !ev.ClearCall && ev.TuneFreqHz == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -2061,6 +2061,41 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
|
|||||||
return out, rows.Err()
|
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
|
// 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
|
// (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
|
// 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
|
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
|
// MarkEQSLConfirmed stamps EQSL_QSL_RCVD=Y and the received date on a QSO after
|
||||||
// an eQSL Inbox download. date is an ADIF YYYYMMDD string.
|
// an eQSL Inbox download. date is an ADIF YYYYMMDD string.
|
||||||
func (r *Repo) MarkEQSLConfirmed(ctx context.Context, id int64, date string) error {
|
func (r *Repo) MarkEQSLConfirmed(ctx context.Context, id int64, date string) error {
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
// Package gs232 drives rotator controllers that speak the Yaesu GS-232A
|
// Package gs232 drives rotator controllers that speak the Yaesu GS-232A
|
||||||
// protocol, over a raw TCP socket or a serial COM port. The target device is
|
// protocol, over a raw TCP socket or a serial COM port.
|
||||||
// the microHAM ARCO: both its LAN "CONTROL PROTOCOL" setting (a TCP port) and
|
//
|
||||||
|
// Any controller set to GS-232A works, which is most of them: the microHAM ARCO
|
||||||
|
// natively, and the ERC family (Easy Rotor Control — ERC Mini, ERC interface),
|
||||||
|
// which EMULATES GS-232A/B over its USB port. An ERC can also be configured for
|
||||||
|
// Hy-Gain DCU-1, a different command set entirely, so it must be set to GS-232.
|
||||||
|
//
|
||||||
|
// On the ARCO: both its LAN "CONTROL PROTOCOL" setting (a TCP port) and
|
||||||
// its USB port ("USB CONTROL PROTOCOL", a virtual COM where the baud rate is
|
// its USB port ("USB CONTROL PROTOCOL", a virtual COM where the baud rate is
|
||||||
// irrelevant) can be set to speak Yaesu GS-232A — so OpsLog controls it
|
// irrelevant) can be set to speak Yaesu GS-232A — so OpsLog controls it
|
||||||
// directly, no PstRotator in between. ARCO accepts up to four parallel LAN
|
// directly, no PstRotator in between. ARCO accepts up to four parallel LAN
|
||||||
@@ -37,7 +43,12 @@ const (
|
|||||||
type Client struct {
|
type Client struct {
|
||||||
Host string
|
Host string
|
||||||
Port int
|
Port int
|
||||||
ComPort string // serial transport: "COM5" etc. (ARCO USB virtual COM — any baud)
|
ComPort string // serial transport: "COM5" etc.
|
||||||
|
// Baud matters on some controllers. An ARCO's USB virtual COM ignores it, but
|
||||||
|
// an ERC (Easy Rotor Control) runs at whatever rate its own configuration
|
||||||
|
// sets — commonly 9600 or 19200 — and a mismatch reads as a dead rotator.
|
||||||
|
// Zero keeps the historical 9600.
|
||||||
|
Baud int
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a TCP Client with sane defaults applied for empty fields. There
|
// New returns a TCP Client with sane defaults applied for empty fields. There
|
||||||
@@ -56,8 +67,8 @@ func New(host string, port int) *Client {
|
|||||||
// NewSerial returns a Client talking over the ARCO's USB virtual COM port. The
|
// NewSerial returns a Client talking over the ARCO's USB virtual COM port. The
|
||||||
// baud rate is irrelevant on USB per the ARCO manual (8N1 framing matters); we
|
// baud rate is irrelevant on USB per the ARCO manual (8N1 framing matters); we
|
||||||
// open at 9600 which also suits a real RS-232 hookup left at its default.
|
// open at 9600 which also suits a real RS-232 hookup left at its default.
|
||||||
func NewSerial(comPort string) *Client {
|
func NewSerial(comPort string, baud int) *Client {
|
||||||
return &Client{ComPort: comPort}
|
return &Client{ComPort: comPort, Baud: baud}
|
||||||
}
|
}
|
||||||
|
|
||||||
// roundTrip opens a connection (TCP or serial per the client's config), sends
|
// roundTrip opens a connection (TCP or serial per the client's config), sends
|
||||||
@@ -66,9 +77,13 @@ func NewSerial(comPort string) *Client {
|
|||||||
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
||||||
var conn io.ReadWriteCloser
|
var conn io.ReadWriteCloser
|
||||||
if c.ComPort != "" {
|
if c.ComPort != "" {
|
||||||
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: 9600})
|
baud := c.Baud
|
||||||
|
if baud <= 0 {
|
||||||
|
baud = 9600
|
||||||
|
}
|
||||||
|
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: baud})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("open ARCO %s: %w", c.ComPort, err)
|
return "", fmt.Errorf("open rotator %s @ %d baud: %w", c.ComPort, baud, err)
|
||||||
}
|
}
|
||||||
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
||||||
conn = sp
|
conn = sp
|
||||||
|
|||||||
@@ -629,3 +629,11 @@ func isTimeout(err error) bool {
|
|||||||
}
|
}
|
||||||
return strings.Contains(strings.ToLower(err.Error()), "timeout")
|
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() }
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/adif"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What counts as a QRZ.com confirmation: app_qrzlog_status = C, and nothing else.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
func TestQRZRecordConfirmed(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
rec adif.Record
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// 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},
|
||||||
|
|
||||||
|
// NOT confirmations by QRZ — the bug being fixed.
|
||||||
|
{"paper QSL received", adif.Record{"qsl_rcvd": "Y"}, false},
|
||||||
|
{"paper QSL plus a card sent", adif.Record{"qsl_rcvd": "Y", "qsl_sent": "Y"}, false},
|
||||||
|
{"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},
|
||||||
|
{"bare record", adif.Record{"call": "F4XYZ"}, false},
|
||||||
|
{"empty", adif.Record{}, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := qrzRecordConfirmed(c.rec); got != c.want {
|
||||||
|
t.Errorf("%s: qrzRecordConfirmed = %v, want %v (%v)", c.name, got, c.want, c.rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.22.0"
|
appVersion = "0.22.7"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user