Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7afe40a48e | ||
|
|
311ea8341f | ||
|
|
a2958d458e | ||
|
|
d9b7e48e83 | ||
|
|
3e9ebdb89a | ||
|
|
be66ac1e19 | ||
|
|
6aab4a6989 | ||
|
|
9c62bf0152 | ||
|
|
1630c8fdf4 | ||
|
|
408791ddf5 | ||
|
|
3de47a8825 | ||
|
|
880ecdbbb5 | ||
|
|
615df0dc10 | ||
|
|
2194279602 | ||
|
|
51c4bda71a | ||
|
|
7d7d175ede | ||
|
|
be1ae76eb3 | ||
|
|
50157a25d3 | ||
|
|
fe1a77a54d | ||
|
|
b818a2d947 | ||
|
|
5c4ae0cfd7 | ||
|
|
69635a15bc | ||
|
|
78220e700f | ||
|
|
42a6b9c76a | ||
|
|
56affa4bed | ||
|
|
aa5af4fc75 | ||
|
|
46772e54fe | ||
|
|
a0cea352ff | ||
|
|
190b86eb1c | ||
|
|
cfc3d00ea1 | ||
|
|
9033e8518c | ||
|
|
bfbd9fa61a | ||
|
|
f0c4f22942 | ||
|
|
19c91f32a0 | ||
|
|
3ec23bc613 | ||
|
|
2fbb922bd2 | ||
|
|
f0afdcc498 | ||
|
|
22352c5748 | ||
|
|
6356d60a66 | ||
|
|
cafade0dbb | ||
|
|
2e39615554 | ||
|
|
666b933114 | ||
|
|
def59da748 | ||
|
|
fe69bc308c | ||
|
|
c07a17dc47 | ||
|
|
3cef885934 | ||
|
|
b8db653981 | ||
|
|
9729ef62ba | ||
|
|
cc6411a618 | ||
|
|
991831bdec | ||
|
|
1b2da95ad4 | ||
|
|
10d86db50a | ||
|
|
8538f48259 | ||
|
|
0fa91c3d5f | ||
|
|
c86d331bd9 | ||
|
|
77a752efe3 | ||
|
|
781fbfaa30 | ||
|
|
61c11c0fe3 | ||
|
|
64b746f007 | ||
|
|
9cc72c7575 | ||
|
|
9e4f43f648 | ||
|
|
5f044b959e | ||
|
|
68a49be8c1 | ||
|
|
8eb82d6cdb | ||
|
|
d327db3f57 | ||
|
|
59e6570f17 | ||
|
|
82a2c6cb7f | ||
|
|
24eaf597fd | ||
|
|
14a22ddb66 | ||
|
|
9156acea5f | ||
|
|
5d0906f00e | ||
|
|
901e967b53 | ||
|
|
4ab4f70349 | ||
|
|
64e80986ea | ||
|
|
816c6ffcf1 | ||
|
|
2166d1aa4b | ||
|
|
0a9a09bec2 | ||
|
|
34ec91684e | ||
|
|
11f1e332f7 | ||
|
|
bd9e091e65 | ||
|
|
d38c783dcc | ||
|
|
c825caa7a8 | ||
|
|
215652570c | ||
|
|
79552bfae1 | ||
|
|
8fc04563e1 | ||
|
|
19993bafc1 | ||
|
|
da1793a902 | ||
|
|
14c87f7fa9 | ||
|
|
9d4ccb9254 | ||
|
|
d30b305ff2 | ||
|
|
5abe4bd0c3 | ||
|
|
3ed9f29d9a | ||
|
|
04eaa91bd8 | ||
|
|
443698b507 | ||
|
|
db2908b3ef | ||
|
|
0838c2ec89 | ||
|
|
80c5fdc095 | ||
|
|
a1be0dfe68 | ||
|
|
ac4039393d | ||
|
|
bb53085c21 | ||
|
|
031dfa8f46 | ||
|
|
6322a425d8 | ||
|
|
9f08df1c39 | ||
|
|
aeeb658269 | ||
|
|
8e088576c7 | ||
|
|
eb2ff8ed59 | ||
|
|
dd3b51a2ae | ||
|
|
1a155e3627 | ||
|
|
cd13921322 | ||
|
|
46e3619a38 | ||
|
|
dcf006905c | ||
|
|
7cf2dfeaf9 | ||
|
|
69229964f4 | ||
|
|
09848adddc | ||
|
|
f853dd479e | ||
|
|
829c236d6c | ||
|
|
c9fd1379e1 | ||
|
|
28b6f04ea4 | ||
|
|
e6744c1d0a | ||
|
|
5152366717 | ||
|
|
e8691a43ba | ||
|
|
b83a4f4455 | ||
|
|
9d7091b1b8 | ||
|
|
f650183936 | ||
|
|
33a6342a07 | ||
|
|
75548812d0 | ||
|
|
5b96f53930 | ||
|
|
d354709939 | ||
|
|
3cd80ead81 | ||
|
|
9e2ffdb758 | ||
|
|
9718b8a78f | ||
|
|
ee844564de | ||
|
|
04b6431726 | ||
|
|
0c6f8e2d68 | ||
|
|
4fe0405432 | ||
|
|
1f0f75baf8 | ||
|
|
7f95a71426 | ||
|
|
08f4b61523 |
+37
-7
@@ -112,7 +112,10 @@ Affiché uniquement quand le backend CAT est une FlexRadio :
|
|||||||
parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro.
|
parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro.
|
||||||
- **Réception (slice active) :** mode/seuil AGC, niveau audio, NB / NR / ANF.
|
- **Réception (slice active) :** mode/seuil AGC, niveau audio, NB / NR / ANF.
|
||||||
- **Coupleur d'antenne (ATU) :** accord / bypass / mémoires.
|
- **Coupleur d'antenne (ATU) :** accord / bypass / mémoires.
|
||||||
- **Amplificateur :** PowerGenius XL operate/standby + défaut.
|
- **Amplificateur :** la carte de commande suit l'ampli configuré —
|
||||||
|
**PowerGenius XL** (operate/standby, mode ventilateur, défaut) ou
|
||||||
|
**SPE Expert** (operate/standby, Marche/Arrêt, niveau Low/Mid/High, barre de
|
||||||
|
puissance de sortie, bande & température).
|
||||||
- **Mesures en direct** via le flux UDP VITA-49 : S-mètre (unités S), puissance
|
- **Mesures en direct** via le flux UDP VITA-49 : S-mètre (unités S), puissance
|
||||||
directe (W), ROS, ALC, température PA, tension, plus les mesures de l'ampli.
|
directe (W), ROS, ALC, température PA, tension, plus les mesures de l'ampli.
|
||||||
|
|
||||||
@@ -159,9 +162,10 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
|||||||
## Keyers & audio
|
## Keyers & audio
|
||||||
|
|
||||||
- **Keyer CW** avec macros et macros sur touches F. Le moteur du keyer est
|
- **Keyer CW** avec macros et macros sur touches F. Le moteur du keyer est
|
||||||
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **Icom** (le keyer
|
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **FlexRadio CWX**
|
||||||
intégré de la radio via CI-V — sans matériel supplémentaire, fonctionne aussi à
|
(le keyer intégré de la radio via l'API SmartSDR — type-ahead et retour arrière,
|
||||||
distance) ou **TCI**.
|
sans WinKeyer ni SmartCAT), **Icom** (le keyer intégré de la radio via CI-V —
|
||||||
|
sans matériel supplémentaire, fonctionne aussi à distance) ou **TCI**.
|
||||||
- **Keyer vocal numérique** (DVK) : enregistrer les messages vocaux F1–F6 et les
|
- **Keyer vocal numérique** (DVK) : enregistrer les messages vocaux F1–F6 et les
|
||||||
émettre.
|
émettre.
|
||||||
- **Enregistrement audio des QSO :** capture continue en tampon glissant ; au
|
- **Enregistrement audio des QSO :** capture continue en tampon glissant ; au
|
||||||
@@ -170,10 +174,24 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
|||||||
|
|
||||||
## Amplis & commutateurs
|
## Amplis & commutateurs
|
||||||
|
|
||||||
- Amplificateur **PowerGenius XL** (4O3A) — TCP direct : operate/standby,
|
- **Amplificateurs** (Réglages → Amplificateur — la carte de commande apparaît
|
||||||
sélecteur de mode ventilateur et affichage des défauts.
|
sur l'onglet FlexRadio, ou seule quand ni Flex ni Icom n'est actif) :
|
||||||
|
- **PowerGenius XL** (4O3A) en TCP direct — operate/standby, sélecteur de mode
|
||||||
|
ventilateur et affichage des défauts.
|
||||||
|
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) via **USB** (COM virtuel) ou le
|
||||||
|
**réseau** (pont RS232-Ethernet) — operate/standby, Marche/Arrêt, niveau de
|
||||||
|
sortie Low / Mid / High, une barre de puissance et le statut en direct (bande,
|
||||||
|
ROS, courant PA, température, avertissements/alarmes).
|
||||||
- Commutateur d'antenne **Antenna Genius** (4O3A) via TCP/GSCP — un widget de
|
- Commutateur d'antenne **Antenna Genius** (4O3A) via TCP/GSCP — un widget de
|
||||||
commutation A/B ancré.
|
commutation A/B ancré.
|
||||||
|
- Panneau **Station Control** (ancrable, widgets réordonnables par glisser) : le
|
||||||
|
**rotor**, la commande d'éléments **Ultrabeam** et des **cartes relais** —
|
||||||
|
WebSwitch 1216H, KMTronic, **Denkovi** USB (bit-bang FT245 D2XX, 4 ou 8 relais)
|
||||||
|
et USB-série générique (CH340 / LCUS, protocole A0) — pour l'alimentation, les
|
||||||
|
antennes et accessoires de la station.
|
||||||
|
- **Relais automatiques** (Réglages) : bascule les relais de Station Control
|
||||||
|
automatiquement selon la fréquence / bande de la radio (comme PstRotator) — par
|
||||||
|
relais, une fenêtre de fréquence ou un ensemble de bandes.
|
||||||
|
|
||||||
## QSL & diplômes
|
## QSL & diplômes
|
||||||
|
|
||||||
@@ -201,6 +219,15 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
|||||||
un début/fin de fenêtre, signale les doublons et tient un tableau de score en
|
un début/fin de fenêtre, signale les doublons et tient un tableau de score en
|
||||||
direct.
|
direct.
|
||||||
|
|
||||||
|
## Statistiques
|
||||||
|
|
||||||
|
- **Tableau de bord des statistiques :** tuiles de synthèse (QSO, indicatifs
|
||||||
|
uniques, entités DXCC, continents, % confirmés) plus des graphiques — QSO **par
|
||||||
|
mode**, un split **CW / phonie / data** par bande, l'**activité dans le temps**
|
||||||
|
(vues glissantes jour / 7 jours / 30 jours / 12 mois), par opérateur et par
|
||||||
|
continent. Filtres par plage de dates, par opérateur et par concours, et une vue
|
||||||
|
**Table** qui reprend chaque graphique.
|
||||||
|
|
||||||
## Statut opérateur en direct (événements spéciaux)
|
## Statut opérateur en direct (événements spéciaux)
|
||||||
|
|
||||||
Pour un indicatif d'événement spécial multi-op sur un journal MySQL partagé (ex.
|
Pour un indicatif d'événement spécial multi-op sur un journal MySQL partagé (ex.
|
||||||
@@ -247,7 +274,10 @@ ce n'est pas un serveur web.
|
|||||||
- **Démarrage automatique :** lancer des programmes externes (WSJT-X, JTAlert,
|
- **Démarrage automatique :** lancer des programmes externes (WSJT-X, JTAlert,
|
||||||
contrôle de rotor…) au démarrage d'OpsLog, en sautant ceux déjà lancés.
|
contrôle de rotor…) au démarrage d'OpsLog, en sautant ceux déjà lancés.
|
||||||
- **Sauvegarde :** sauvegarde optionnelle base + ADIF à la fermeture.
|
- **Sauvegarde :** sauvegarde optionnelle base + ADIF à la fermeture.
|
||||||
- **Vérification de mise à jour** au démarrage avec un toast (désactivable).
|
- **Vérification de mise à jour** au démarrage et toutes les 5 minutes (et à
|
||||||
|
l'ouverture d'Aide → À propos), avec un toast (désactivable), plus une fenêtre
|
||||||
|
**Nouveautés** qui affiche le changelog (anglais / français) au premier
|
||||||
|
lancement après une mise à jour — réouvrable à tout moment depuis le menu Aide.
|
||||||
- **Télémétrie d'usage anonyme** (un battement de cœur quotidien : ID
|
- **Télémétrie d'usage anonyme** (un battement de cœur quotidien : ID
|
||||||
d'installation aléatoire + version + OS — aucune donnée d'indicatif ou de QSO ;
|
d'installation aléatoire + version + OS — aucune donnée d'indicatif ou de QSO ;
|
||||||
désactivable dans les Préférences).
|
désactivable dans les Préférences).
|
||||||
|
|||||||
@@ -100,7 +100,10 @@ Shown only when the CAT backend is a FlexRadio:
|
|||||||
VOX (+ level + delay), monitor (+ level), mic gain.
|
VOX (+ level + delay), monitor (+ level), mic gain.
|
||||||
- **Receive (active slice):** AGC mode/threshold, audio level, NB / NR / ANF.
|
- **Receive (active slice):** AGC mode/threshold, audio level, NB / NR / ANF.
|
||||||
- **Antenna tuner (ATU):** tune / bypass / memories.
|
- **Antenna tuner (ATU):** tune / bypass / memories.
|
||||||
- **Amplifier:** PowerGenius XL operate/standby + fault.
|
- **Amplifier:** the amp card follows whichever amplifier is configured —
|
||||||
|
**PowerGenius XL** (operate/standby, fan mode, fault) or **SPE Expert**
|
||||||
|
(operate/standby, ON/OFF, Low/Mid/High level, output-power bar, band &
|
||||||
|
temperature).
|
||||||
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
|
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
|
||||||
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
||||||
|
|
||||||
@@ -141,18 +144,33 @@ as Mumble.)
|
|||||||
## Keyers & audio
|
## Keyers & audio
|
||||||
|
|
||||||
- **CW keyer** with macros and F-key macros. The keyer engine is selectable:
|
- **CW keyer** with macros and F-key macros. The keyer engine is selectable:
|
||||||
**WinKeyer** (K1EL WK1/2/3 over a COM port), **Icom** (the radio's own keyer
|
**WinKeyer** (K1EL WK1/2/3 over a COM port), **FlexRadio CWX** (the radio's
|
||||||
over CI-V — no extra hardware, works over the remote link too) or **TCI**.
|
built-in keyer over the SmartSDR API — type-ahead and backspace, no WinKeyer or
|
||||||
|
SmartCAT needed), **Icom** (the radio's own keyer over CI-V — no extra hardware,
|
||||||
|
works over the remote link too) or **TCI**.
|
||||||
- **Digital Voice Keyer** (DVK): record F1–F6 voice messages and transmit them.
|
- **Digital Voice Keyer** (DVK): record F1–F6 voice messages and transmit them.
|
||||||
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
|
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
|
||||||
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
||||||
|
|
||||||
## Amplifiers & switches
|
## Amplifiers & switches
|
||||||
|
|
||||||
- **PowerGenius XL** (4O3A) amplifier — direct TCP: operate/standby, fan-mode
|
- **Amplifiers** (Settings → Amplifier — the control card appears on the
|
||||||
selector and fault display.
|
FlexRadio tab, or on its own when neither Flex nor Icom is active):
|
||||||
|
- **PowerGenius XL** (4O3A) over direct TCP — operate/standby, fan-mode
|
||||||
|
selector and fault display.
|
||||||
|
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) over **USB** (virtual COM) or the
|
||||||
|
**network** (RS232-to-Ethernet bridge) — operate/standby, ON/OFF,
|
||||||
|
Low / Mid / High output level, an output-power bar and live status (band,
|
||||||
|
SWR, PA current, temperature, warnings/alarms).
|
||||||
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
||||||
antenna-switch widget.
|
antenna-switch widget.
|
||||||
|
- **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**,
|
||||||
|
**Ultrabeam** element control and **relay boards** — WebSwitch 1216H, KMTronic,
|
||||||
|
**Denkovi** USB (FT245 D2XX bit-bang, 4 or 8 relays) and generic USB-serial
|
||||||
|
(CH340 / LCUS, A0 protocol) — for station power, antennas and accessories.
|
||||||
|
- **Relay auto-control** (Settings): switch Station-Control relays automatically
|
||||||
|
from the rig frequency / band (like PstRotator) — per relay, a frequency window
|
||||||
|
or a set of bands.
|
||||||
|
|
||||||
## QSL & awards
|
## QSL & awards
|
||||||
|
|
||||||
@@ -176,6 +194,14 @@ as Mumble.)
|
|||||||
and the sent/received serials (`STX` / `SRX`), enforces a window start/end,
|
and the sent/received serials (`STX` / `SRX`), enforces a window start/end,
|
||||||
flags dupes and keeps a live scoreboard.
|
flags dupes and keeps a live scoreboard.
|
||||||
|
|
||||||
|
## Statistics
|
||||||
|
|
||||||
|
- **Logbook statistics dashboard:** headline tiles (QSOs, unique callsigns, DXCC
|
||||||
|
entities, continents, % confirmed) plus charts — QSOs **by mode**, a per-band
|
||||||
|
**CW / phone / data** split, **activity over time** (rolling day / 7-day /
|
||||||
|
30-day / 12-month views), by operator and by continent. Date-range, per-operator
|
||||||
|
and per-contest filters, and a **Table** view that mirrors every chart.
|
||||||
|
|
||||||
## Multi-operator live status (special events)
|
## Multi-operator live status (special events)
|
||||||
|
|
||||||
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**):
|
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**):
|
||||||
@@ -218,7 +244,10 @@ only writes to the DB — it is not a web server.
|
|||||||
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
||||||
OpsLog startup, skipping any already running.
|
OpsLog startup, skipping any already running.
|
||||||
- **Backup:** optional database + ADIF backup at shutdown.
|
- **Backup:** optional database + ADIF backup at shutdown.
|
||||||
- **Update check** at startup with a toast (toggleable).
|
- **Update check** at startup and every 5 minutes (and on opening Help → About),
|
||||||
|
with a toast (toggleable), plus a **What's new** dialog that shows the changelog
|
||||||
|
(English / French) on the first launch after an update — reopenable any time
|
||||||
|
from the Help menu.
|
||||||
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
||||||
version + OS — no callsign or QSO data; opt-out in Preferences).
|
version + OS — no callsign or QSO data; opt-out in Preferences).
|
||||||
|
|
||||||
|
|||||||
+251
@@ -0,0 +1,251 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// ADIF monitor: watches a configurable list of external ADIF files (fldigi's
|
||||||
|
// RTTY logbook, N1MM, VarAC…) and imports newly appended QSOs into OpsLog as if
|
||||||
|
// they had been logged here — same enrichment, dedup and automatic upload to
|
||||||
|
// external services (QRZ, Club Log…). Deliberately simpler than Log4OM's monitor:
|
||||||
|
// no per-file "upload" / "delete after load" toggles — importing + auto-upload is
|
||||||
|
// just what happens.
|
||||||
|
//
|
||||||
|
// Design notes:
|
||||||
|
// - A newly added file starts at its CURRENT size (Offset = -1 sentinel → set to
|
||||||
|
// size on first scan) so the QSOs already in it are NOT bulk-imported; only
|
||||||
|
// contacts appended AFTER you add the file come in.
|
||||||
|
// - Reads only up to the last complete <eor>, so a half-written record waits for
|
||||||
|
// the next poll instead of importing a truncated QSO.
|
||||||
|
// - Each record is fed through LogUDPLoggedADIF, which already does the full
|
||||||
|
// enrichment + ±2-minute dedup (shared udpLogMu, so it can't race the UDP
|
||||||
|
// auto-log) + automatic external-service upload.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyADIFMonitor = "adifmon.config"
|
||||||
|
|
||||||
|
// ADIFWatchFile is one monitored ADIF file.
|
||||||
|
type ADIFWatchFile struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// Offset is the number of bytes already consumed. -1 means "not yet
|
||||||
|
// initialised": the first scan sets it to the file's current size so existing
|
||||||
|
// history is skipped.
|
||||||
|
Offset int64 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADIFMonitorConfig is the whole monitor setup: a master switch + the file list.
|
||||||
|
type ADIFMonitorConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Files []ADIFWatchFile `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const eorTag = "<eor>"
|
||||||
|
|
||||||
|
func (a *App) loadADIFMonitorLocked() ADIFMonitorConfig {
|
||||||
|
var cfg ADIFMonitorConfig
|
||||||
|
if a.settings == nil {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
s, _ := a.settings.GetGlobal(a.ctx, keyADIFMonitor)
|
||||||
|
if strings.TrimSpace(s) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(s), &cfg)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) saveADIFMonitorLocked(cfg ADIFMonitorConfig) {
|
||||||
|
b, _ := json.Marshal(cfg)
|
||||||
|
a.setSettingGlobal(keyADIFMonitor, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetADIFMonitor returns the monitor configuration for the settings UI.
|
||||||
|
func (a *App) GetADIFMonitor() ADIFMonitorConfig {
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
defer a.adifMonMu.Unlock()
|
||||||
|
return a.loadADIFMonitorLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveADIFMonitor persists the monitor configuration. A file the user just added
|
||||||
|
// gets Offset = -1 so its existing content is skipped (only QSOs logged AFTER it
|
||||||
|
// was added import); a file already present keeps its current read position.
|
||||||
|
func (a *App) SaveADIFMonitor(cfg ADIFMonitorConfig) error {
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
defer a.adifMonMu.Unlock()
|
||||||
|
old := a.loadADIFMonitorLocked()
|
||||||
|
oldOff := make(map[string]int64, len(old.Files))
|
||||||
|
for _, f := range old.Files {
|
||||||
|
oldOff[strings.TrimSpace(f.Path)] = f.Offset
|
||||||
|
}
|
||||||
|
for i := range cfg.Files {
|
||||||
|
cfg.Files[i].Path = strings.TrimSpace(cfg.Files[i].Path)
|
||||||
|
if off, ok := oldOff[cfg.Files[i].Path]; ok {
|
||||||
|
cfg.Files[i].Offset = off // keep the read position of an existing file
|
||||||
|
} else {
|
||||||
|
cfg.Files[i].Offset = -1 // new file → skip its existing history
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.saveADIFMonitorLocked(cfg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickADIFMonitorFile opens a file dialog to choose an ADIF file to monitor.
|
||||||
|
func (a *App) PickADIFMonitorFile() (string, error) {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return "", fmt.Errorf("no app context")
|
||||||
|
}
|
||||||
|
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||||
|
Title: "Choose an ADIF file to monitor",
|
||||||
|
Filters: []wruntime.FileFilter{
|
||||||
|
{DisplayName: "ADIF (*.adi;*.adif)", Pattern: "*.adi;*.adif"},
|
||||||
|
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adifMonitorLoop polls the enabled ADIF files every few seconds and imports any
|
||||||
|
// newly appended QSOs. Runs for the app's lifetime on its own goroutine.
|
||||||
|
func (a *App) adifMonitorLoop() {
|
||||||
|
tick := time.NewTicker(5 * time.Second)
|
||||||
|
defer tick.Stop()
|
||||||
|
for range tick.C {
|
||||||
|
if a.ctx == nil || a.qso == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.scanADIFMonitors()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanADIFMonitors walks the enabled files once, importing new records and
|
||||||
|
// persisting advanced offsets.
|
||||||
|
func (a *App) scanADIFMonitors() {
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
cfg := a.loadADIFMonitorLocked()
|
||||||
|
a.adifMonMu.Unlock()
|
||||||
|
if !cfg.Enabled || len(cfg.Files) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// path → new offset, for the files we advanced this pass.
|
||||||
|
advanced := map[string]int64{}
|
||||||
|
for i := range cfg.Files {
|
||||||
|
f := &cfg.Files[i]
|
||||||
|
if !f.Enabled || strings.TrimSpace(f.Path) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(f.Path)
|
||||||
|
if err != nil {
|
||||||
|
continue // not present (yet) — try again next tick
|
||||||
|
}
|
||||||
|
size := fi.Size()
|
||||||
|
if f.Offset < 0 {
|
||||||
|
// First sight of this file → skip whatever history it already holds.
|
||||||
|
advanced[f.Path] = size
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if size < f.Offset {
|
||||||
|
f.Offset = 0 // truncated / rotated → re-read from the start (dedup protects us)
|
||||||
|
}
|
||||||
|
if size == f.Offset {
|
||||||
|
continue // nothing new
|
||||||
|
}
|
||||||
|
newOff, n := a.importADIFAppend(f.Path, f.Offset, size)
|
||||||
|
if newOff != f.Offset {
|
||||||
|
advanced[f.Path] = newOff
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
applog.Printf("adif monitor: imported %d QSO(s) from %s", n, f.Path)
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "adifmon:imported", map[string]any{"file": f.Path, "count": n})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(advanced) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Persist the advanced offsets WITHOUT clobbering a concurrent UI save of the
|
||||||
|
// file list: re-read the stored config and only patch offsets for paths that
|
||||||
|
// still exist there.
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
cur := a.loadADIFMonitorLocked()
|
||||||
|
for i := range cur.Files {
|
||||||
|
if off, ok := advanced[strings.TrimSpace(cur.Files[i].Path)]; ok {
|
||||||
|
cur.Files[i].Offset = off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.saveADIFMonitorLocked(cur)
|
||||||
|
a.adifMonMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// importADIFAppend reads bytes [from,to) of an ADIF file, imports every COMPLETE
|
||||||
|
// record found (up to the last <eor>) and returns the new offset (just past that
|
||||||
|
// last <eor>) plus how many QSOs were actually imported (duplicates excluded).
|
||||||
|
func (a *App) importADIFAppend(path string, from, to int64) (int64, int) {
|
||||||
|
fh, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return from, 0
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
if _, err := fh.Seek(from, io.SeekStart); err != nil {
|
||||||
|
return from, 0
|
||||||
|
}
|
||||||
|
buf := make([]byte, to-from)
|
||||||
|
nRead, err := io.ReadFull(fh, buf)
|
||||||
|
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||||
|
return from, 0
|
||||||
|
}
|
||||||
|
buf = buf[:nRead]
|
||||||
|
|
||||||
|
// Only consume up to the last complete <eor>; a half-written trailing record
|
||||||
|
// waits for the next poll.
|
||||||
|
lower := bytes.ToLower(buf)
|
||||||
|
last := bytes.LastIndex(lower, []byte(eorTag))
|
||||||
|
if last < 0 {
|
||||||
|
return from, 0 // no complete record yet
|
||||||
|
}
|
||||||
|
end := last + len(eorTag)
|
||||||
|
chunk := buf[:end]
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for _, rec := range splitADIFRecords(chunk) {
|
||||||
|
if strings.TrimSpace(rec) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := a.LogUDPLoggedADIF(rec); err == nil {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
// A duplicate (already in the log within ±2 min) returns an error and is
|
||||||
|
// simply not counted — expected when the same QSO is also logged in OpsLog.
|
||||||
|
}
|
||||||
|
return from + int64(end), count
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitADIFRecords cuts an ADIF byte slice into individual record texts, each
|
||||||
|
// ending at its <eor> (case-insensitive). Any leading file header (up to the
|
||||||
|
// first record's <eor>) rides along with the first record — LogUDPLoggedADIF
|
||||||
|
// parses past an <EOH> header fine, and prepends one when there is none.
|
||||||
|
func splitADIFRecords(b []byte) []string {
|
||||||
|
lower := bytes.ToLower(b)
|
||||||
|
var out []string
|
||||||
|
start := 0
|
||||||
|
for {
|
||||||
|
rel := bytes.Index(lower[start:], []byte(eorTag))
|
||||||
|
if rel < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end := start + rel + len(eorTag)
|
||||||
|
out = append(out, string(b[start:end]))
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
|
||||||
"hamlog/internal/audio"
|
|
||||||
"hamlog/internal/cwdecode"
|
|
||||||
|
|
||||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK
|
|
||||||
// and QSO recorder use) and streams decoded Morse text to the UI. It is started
|
|
||||||
// only by the frontend, and only while the entry mode is CW.
|
|
||||||
//
|
|
||||||
// Pitch targeting: the single-channel decoder is far more reliable when it locks
|
|
||||||
// to a KNOWN pitch (a narrow filter at the signal frequency, like a skimmer)
|
|
||||||
// instead of auto-searching for the loudest tone. So we follow the radio's CW
|
|
||||||
// pitch (FlexRadio cw_pitch) when available — or a manual override — and fall
|
|
||||||
// back to auto-search otherwise.
|
|
||||||
|
|
||||||
// cwTargetPitch returns the pitch (Hz) the decoder should lock to: the manual
|
|
||||||
// override if set, else the FlexRadio's CW pitch when it's in CW, else 0 (auto).
|
|
||||||
func (a *App) cwTargetPitch() int {
|
|
||||||
if a.cwPitchHz > 0 {
|
|
||||||
return a.cwPitchHz
|
|
||||||
}
|
|
||||||
if a.cat != nil {
|
|
||||||
if st, ok := a.cat.FlexState(); ok && st.Available {
|
|
||||||
// Only trust the radio's pitch when it's actually in CW.
|
|
||||||
if st.Mode == "CW" || st.Mode == "CWL" || st.Mode == "CWU" {
|
|
||||||
if st.CWPitch > 0 {
|
|
||||||
return st.CWPitch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartCWDecoder begins decoding CW from the configured RX audio device. The
|
|
||||||
// frontend calls this when the decoder toggle is on AND the mode is CW. Safe to
|
|
||||||
// call repeatedly; a second call is a no-op while already running.
|
|
||||||
func (a *App) StartCWDecoder() error {
|
|
||||||
a.cwMu.Lock()
|
|
||||||
defer a.cwMu.Unlock()
|
|
||||||
if a.cwStop != nil {
|
|
||||||
return nil // already running
|
|
||||||
}
|
|
||||||
dev := ""
|
|
||||||
if a.settings != nil {
|
|
||||||
dev, _ = a.settings.Get(a.ctx, keyAudioFromRadio)
|
|
||||||
}
|
|
||||||
if dev == "" {
|
|
||||||
return fmt.Errorf("no RX audio device configured (set \"From radio\" in Audio settings)")
|
|
||||||
}
|
|
||||||
|
|
||||||
dec := cwdecode.New(audio.SampleRate,
|
|
||||||
func(text string) {
|
|
||||||
if a.ctx != nil {
|
|
||||||
wruntime.EventsEmit(a.ctx, "cw:text", text)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
func(st cwdecode.Status) {
|
|
||||||
if a.ctx != nil {
|
|
||||||
wruntime.EventsEmit(a.ctx, "cw:status", st)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
dec.SetTarget(a.cwTargetPitch())
|
|
||||||
a.cwDecoder = dec
|
|
||||||
|
|
||||||
stop := make(chan struct{})
|
|
||||||
a.cwStop = stop
|
|
||||||
go func() {
|
|
||||||
if err := audio.StreamCapture(dev, stop, dec.Process); err != nil {
|
|
||||||
applog.Printf("cw: capture failed: %v", err)
|
|
||||||
if a.ctx != nil {
|
|
||||||
wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a.cwMu.Lock()
|
|
||||||
if a.cwStop == stop {
|
|
||||||
a.cwStop = nil
|
|
||||||
a.cwDecoder = nil
|
|
||||||
}
|
|
||||||
a.cwMu.Unlock()
|
|
||||||
}()
|
|
||||||
// Follow the radio's CW pitch live (every second) while this run is active.
|
|
||||||
go a.cwFollowPitch(stop, dec)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// cwFollowPitch keeps the decoder locked to the current target pitch until stop.
|
|
||||||
func (a *App) cwFollowPitch(stop <-chan struct{}, dec *cwdecode.Decoder) {
|
|
||||||
t := time.NewTicker(time.Second)
|
|
||||||
defer t.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
case <-t.C:
|
|
||||||
dec.SetTarget(a.cwTargetPitch())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopCWDecoder halts the CW decoder if running.
|
|
||||||
func (a *App) StopCWDecoder() {
|
|
||||||
a.cwMu.Lock()
|
|
||||||
stop := a.cwStop
|
|
||||||
a.cwStop = nil
|
|
||||||
a.cwDecoder = nil
|
|
||||||
a.cwMu.Unlock()
|
|
||||||
if stop != nil {
|
|
||||||
close(stop)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CWDecoderRunning reports whether the decoder is currently capturing.
|
|
||||||
func (a *App) CWDecoderRunning() bool {
|
|
||||||
a.cwMu.Lock()
|
|
||||||
defer a.cwMu.Unlock()
|
|
||||||
return a.cwStop != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetCWDecoderPitch sets a manual decode pitch (Hz); 0 returns to auto (follow
|
|
||||||
// the Flex CW pitch, or search). Applies live to a running decoder.
|
|
||||||
func (a *App) SetCWDecoderPitch(hz int) {
|
|
||||||
if hz < 0 {
|
|
||||||
hz = 0
|
|
||||||
}
|
|
||||||
a.cwMu.Lock()
|
|
||||||
a.cwPitchHz = hz
|
|
||||||
dec := a.cwDecoder
|
|
||||||
a.cwMu.Unlock()
|
|
||||||
if dec != nil {
|
|
||||||
dec.SetTarget(a.cwTargetPitch())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCWDecoderPitch returns the manual override (0 = auto / follow Flex).
|
|
||||||
func (a *App) GetCWDecoderPitch() int {
|
|
||||||
a.cwMu.Lock()
|
|
||||||
defer a.cwMu.Unlock()
|
|
||||||
return a.cwPitchHz
|
|
||||||
}
|
|
||||||
+10
-11
@@ -437,17 +437,16 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
|
|||||||
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
|
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — NOT
|
||||||
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
|
// the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay independent.
|
||||||
// independent. q came straight from GetByID, so a full Update rewrites the
|
//
|
||||||
// row unchanged apart from this field.
|
// Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q`
|
||||||
if q.Extras == nil {
|
// read up top is now stale after the slow e-mail send, and rewriting the whole
|
||||||
q.Extras = map[string]string{}
|
// row would revert any column an auto-upload changed meanwhile — that's how
|
||||||
}
|
// sending a QSL card was flipping clublog_qso_upload_status back from Y to R.
|
||||||
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
|
if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err)
|
||||||
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
|
return fmt.Errorf("QSL card sent but status not saved: %w", err)
|
||||||
return fmt.Errorf("eQSL sent but status not saved: %w", err)
|
|
||||||
}
|
}
|
||||||
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
||||||
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/award"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The catalog is the channel through which a shipped award is both DELIVERED and
|
||||||
|
// CORRECTED. These tests pin the three rules that make that safe:
|
||||||
|
// - a new catalog award reaches an operator who already has awards stored;
|
||||||
|
// - a fixed one (higher Version) replaces the stored copy;
|
||||||
|
// - unless the operator has edited it, in which case their work wins.
|
||||||
|
|
||||||
|
func catalogDef(t *testing.T, code string) award.Def {
|
||||||
|
t.Helper()
|
||||||
|
for _, d := range award.Defaults() {
|
||||||
|
if d.Code == code {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("%s is not in the catalog", code)
|
||||||
|
return award.Def{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findDef(defs []award.Def, code string) (award.Def, bool) {
|
||||||
|
for _, d := range defs {
|
||||||
|
if d.Code == code {
|
||||||
|
return d, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return award.Def{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeCatalogAddsMissingAward(t *testing.T) {
|
||||||
|
stored := []award.Def{{Code: "MYOWN", Name: "Mine", Valid: true}}
|
||||||
|
got, updated, changed := mergeCatalog(stored)
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("merge reported no change, but every catalog award was missing")
|
||||||
|
}
|
||||||
|
if len(updated) != 0 {
|
||||||
|
t.Errorf("updated = %v, want none: an ADDED award is not an UPDATED one", updated)
|
||||||
|
}
|
||||||
|
if _, ok := findDef(got, "FFMA"); !ok {
|
||||||
|
t.Error("FFMA was not added — a newly shipped award never reaches an existing install")
|
||||||
|
}
|
||||||
|
if _, ok := findDef(got, "MYOWN"); !ok {
|
||||||
|
t.Error("the operator's own award was dropped by the merge")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeCatalogUpdatesOlderVersion(t *testing.T) {
|
||||||
|
// The stored copy is an older revision of a shipped award, with a broken rule.
|
||||||
|
old := catalogDef(t, "FFMA")
|
||||||
|
old.Version = catalogDef(t, "FFMA").Version - 1
|
||||||
|
old.Field = "wrong"
|
||||||
|
old.Valid = false // the operator disabled it — a preference, not a definition
|
||||||
|
|
||||||
|
got, updated, changed := mergeCatalog([]award.Def{old})
|
||||||
|
if !changed || len(updated) == 0 {
|
||||||
|
t.Fatal("a higher catalog version did not replace the stored definition — a shipped award could never be FIXED")
|
||||||
|
}
|
||||||
|
d, _ := findDef(got, "FFMA")
|
||||||
|
if d.Field != "grid4" {
|
||||||
|
t.Errorf("FFMA field = %q, want the catalog's %q", d.Field, "grid4")
|
||||||
|
}
|
||||||
|
if d.Valid {
|
||||||
|
t.Error("the update re-enabled an award the operator had switched off; that is their choice to make, not ours")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeCatalogSkipsUserEdited(t *testing.T) {
|
||||||
|
old := catalogDef(t, "FFMA")
|
||||||
|
old.Version = catalogDef(t, "FFMA").Version - 1
|
||||||
|
old.Field = "mine"
|
||||||
|
old.UserEdited = true
|
||||||
|
|
||||||
|
got, updated, _ := mergeCatalog([]award.Def{old})
|
||||||
|
if len(updated) != 0 {
|
||||||
|
t.Fatalf("updated = %v: an award the operator has edited must never be overwritten", updated)
|
||||||
|
}
|
||||||
|
if d, _ := findDef(got, "FFMA"); d.Field != "mine" {
|
||||||
|
t.Errorf("field = %q, want the operator's %q", d.Field, "mine")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkUserEditedOnlyOnRealChange(t *testing.T) {
|
||||||
|
prev := []award.Def{
|
||||||
|
{Code: "A", Name: "A", Field: "state", Valid: true, Version: 2},
|
||||||
|
{Code: "B", Name: "B", Field: "cqz", Valid: true, Version: 2},
|
||||||
|
}
|
||||||
|
next := []award.Def{
|
||||||
|
{Code: "A", Name: "A", Field: "state", Valid: true}, // untouched
|
||||||
|
{Code: "B", Name: "B", Field: "county", Valid: true}, // changed
|
||||||
|
{Code: "C", Name: "C", Field: "note", Valid: true}, // brand new
|
||||||
|
}
|
||||||
|
markUserEdited(next, prev)
|
||||||
|
|
||||||
|
if next[0].UserEdited {
|
||||||
|
t.Error("A was flagged as edited although nothing about it changed — every save would freeze every award out of future updates")
|
||||||
|
}
|
||||||
|
if !next[1].UserEdited {
|
||||||
|
t.Error("B changed field and was not flagged; a catalog update would overwrite the operator's work")
|
||||||
|
}
|
||||||
|
if next[2].UserEdited {
|
||||||
|
t.Error("C is a brand-new award; there is no shipped version to protect it from")
|
||||||
|
}
|
||||||
|
// A save must not pretend to be a new shipped revision.
|
||||||
|
if next[0].Version != 2 || next[1].Version != 2 {
|
||||||
|
t.Errorf("versions = %d/%d, want both 2: saving is not shipping", next[0].Version, next[1].Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed changelog.json
|
||||||
|
var changelogJSON []byte
|
||||||
|
|
||||||
|
// ChangelogEntry is one release's user-facing notes, in English and French. It's
|
||||||
|
// shown once on the first launch after an update (the "What's new" dialog),
|
||||||
|
// derived from the release's commit history.
|
||||||
|
type ChangelogEntry struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
EN []string `json:"en"`
|
||||||
|
FR []string `json:"fr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyChangelogSeen = "changelog.last_seen_version"
|
||||||
|
|
||||||
|
func loadChangelog() []ChangelogEntry {
|
||||||
|
var out []ChangelogEntry
|
||||||
|
if err := json.Unmarshal(changelogJSON, &out); err != nil {
|
||||||
|
applog.Printf("changelog: parse failed: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChangelog returns every changelog entry up to the running version (newest
|
||||||
|
// first), without touching the "seen" marker — for a "What's new" button that
|
||||||
|
// reopens the notes on demand.
|
||||||
|
func (a *App) GetChangelog() []ChangelogEntry {
|
||||||
|
out := []ChangelogEntry{}
|
||||||
|
for _, e := range loadChangelog() {
|
||||||
|
if strings.TrimSpace(e.Version) == "" || versionLess(appVersion, e.Version) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWhatsNew returns the changelog entries the operator hasn't seen yet — the
|
||||||
|
// notes for every version newer than the last one they ran, up to the current
|
||||||
|
// build — and records the current version as seen so it pops exactly once per
|
||||||
|
// update. Empty when there's nothing new.
|
||||||
|
func (a *App) GetWhatsNew() []ChangelogEntry {
|
||||||
|
if a.settings == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastSeen, _ := a.settings.GetGlobal(a.ctx, keyChangelogSeen)
|
||||||
|
cur := appVersion
|
||||||
|
a.setSettingGlobal(keyChangelogSeen, cur) // advance the marker now
|
||||||
|
|
||||||
|
out := []ChangelogEntry{}
|
||||||
|
for _, e := range loadChangelog() {
|
||||||
|
if strings.TrimSpace(e.Version) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if versionLess(cur, e.Version) {
|
||||||
|
continue // don't preview notes for a version newer than we run
|
||||||
|
}
|
||||||
|
if lastSeen == "" {
|
||||||
|
// First run of the feature (fresh install, or upgrade from a build that
|
||||||
|
// predates it): show only the CURRENT version's notes, once.
|
||||||
|
if e.Version == cur {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if versionLess(lastSeen, e.Version) {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"version": "0.20.6",
|
||||||
|
"date": "2026-07-21",
|
||||||
|
"en": [
|
||||||
|
"Amplifier controls now live right in the FlexRadio panel: OPERATE/STANDBY, ON/OFF, Low/Mid/High power level, an output-power bar and live band / SWR / temperature — for both PowerGenius XL and SPE Expert. The card is hidden when no amplifier is selected.",
|
||||||
|
"Faster startup: the radio (CAT) connects immediately instead of waiting behind the shared-MySQL connection, and MySQL itself connects much faster.",
|
||||||
|
"No more lost QSOs: an FT8/MSHV QSO that can't reach a laggy shared database is parked and re-logged automatically once it recovers; simultaneous QSOs from a multi-stream are now queued instead of failing with 'too many requests'.",
|
||||||
|
"The 'stations on air' widget now updates within a second of a QSO (it used to lag up to ~45s behind the ON-AIR badge).",
|
||||||
|
"Relay boards (Denkovi / USB-serial) stay connected reliably now, with a Test-connection button and detect feedback in the Station Control setup.",
|
||||||
|
"<LOGQSO> in a CW macro logs the contact at that exact point in the macro, and pressing ESC before it runs cancels the log.",
|
||||||
|
"Auto-call now waits exactly the gap you set between calls (it was waiting far longer).",
|
||||||
|
"Statistics: the redundant per-band chart was merged into the CW / phone / data split.",
|
||||||
|
"Cluster self-spot pop-ups now show the band.",
|
||||||
|
"The bottom status bar is no longer hidden behind a scrollbar in a small window.",
|
||||||
|
"Update check now also runs when you open Help - About, and every 5 minutes."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les commandes d'amplificateur sont maintenant directement dans le panneau FlexRadio : OPERATE/STANDBY, Marche/Arrêt, niveau Low/Mid/High, une barre de puissance de sortie et l'état en direct bande / ROS / température — pour PowerGenius XL comme pour SPE Expert. La carte est masquée si aucun ampli n'est sélectionné.",
|
||||||
|
"Démarrage plus rapide : la radio (CAT) se connecte immédiatement au lieu d'attendre derrière la connexion MySQL partagée, et MySQL lui-même se connecte bien plus vite.",
|
||||||
|
"Plus de QSO perdus : un QSO FT8/MSHV qui n'atteint pas une base partagée lente est mis de côté et ré-enregistré automatiquement dès qu'elle répond ; les QSO simultanés d'un multi-stream sont maintenant mis en file au lieu d'échouer avec « too many requests ».",
|
||||||
|
"Le widget « stations on air » se met à jour en une seconde après un QSO (il pouvait accuser jusqu'à ~45s de retard sur le badge ON AIR).",
|
||||||
|
"Les cartes relais (Denkovi / USB-série) restent connectées de façon fiable, avec un bouton « Tester la connexion » et un retour de détection dans la configuration Station Control.",
|
||||||
|
"<LOGQSO> dans une macro CW enregistre le contact à cet endroit précis de la macro, et appuyer sur ÉCHAP avant son exécution annule l'enregistrement.",
|
||||||
|
"L'appel automatique respecte maintenant exactement l'intervalle réglé entre les appels (il attendait bien plus longtemps).",
|
||||||
|
"Statistiques : le graphique par bande redondant a été fusionné avec la répartition CW / phone / data.",
|
||||||
|
"Les popups d'auto-spot du cluster affichent maintenant la bande.",
|
||||||
|
"La barre de statut du bas n'est plus masquée par une barre de défilement en fenêtre réduite.",
|
||||||
|
"La vérification de mise à jour se déclenche aussi à l'ouverture d'Aide - À propos, et toutes les 5 minutes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.20.5",
|
||||||
|
"date": "2026-07-20",
|
||||||
|
"en": [
|
||||||
|
"New FlexRadio CWX CW keyer: key CW straight over the SmartSDR connection — no WinKeyer or SmartCAT needed. Type-ahead and mid-send backspace supported.",
|
||||||
|
"ESC now stops CW on whichever keyer is active (WinKeyer / Icom / Flex), and typing a callsign aborts a running macro.",
|
||||||
|
"New Amplifier settings section (was 'Power Genius'): control SPE Expert amps (1.3K / 1.5K / 2K) over USB serial or an RS232-to-Ethernet bridge — OPERATE/STANDBY and live status. PowerGenius XL still supported.",
|
||||||
|
"Denkovi USB relay boards supported (4/8 relays, FT245), plus generic CH340/LCUS USB-serial relay boards.",
|
||||||
|
"Rename your database from Settings without losing any configuration.",
|
||||||
|
"Awards: DDFM now finds the French department from the address postal code; refs are stored on each QSO for faster columns; 'Publish to catalog' shares an edited award with your team on the next release; award columns are remembered per profile.",
|
||||||
|
"Statistics: per-band mode split (CW / phone / data), and an activity chart with a Day / Week / Month / Year selector (rolling, real dates).",
|
||||||
|
"'Stations on air' widget updates within seconds and fits more stations.",
|
||||||
|
"The audio CW decoder was removed.",
|
||||||
|
"Fixes: Antenna Genius buttons ignored clicks while a macro was sending; File → Exit; and more.",
|
||||||
|
"This 'What's new' summary now appears on the first launch after each update."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Nouveau keyer CW FlexRadio CWX : manipulation CW directement via la connexion SmartSDR — plus besoin de WinKeyer ni de SmartCAT. Type-ahead et retour arrière en cours d'envoi supportés.",
|
||||||
|
"ESC arrête maintenant le CW sur le moteur actif (WinKeyer / Icom / Flex), et taper un indicatif interrompt une macro en cours.",
|
||||||
|
"Nouvelle section Amplificateur (ex « Power Genius ») : pilotage des SPE Expert (1.3K / 1.5K / 2K) en USB série ou via un pont RS232→Ethernet — OPERATE/STANDBY et état en direct. PowerGenius XL toujours supporté.",
|
||||||
|
"Cartes relais USB Denkovi (4/8 relais, FT245), plus cartes USB-série génériques CH340/LCUS.",
|
||||||
|
"Renommer sa base depuis les réglages sans perdre la configuration.",
|
||||||
|
"Awards : DDFM trouve le département français depuis le code postal de l'adresse ; les réfs sont stockées sur chaque QSO (colonnes plus rapides) ; « Publier au catalogue » partage un award édité avec l'équipe à la prochaine mise à jour ; les colonnes d'award sont mémorisées par profil.",
|
||||||
|
"Statistiques : répartition des modes par bande (CW / phone / data), et un graphe d'activité avec sélecteur Jour / Semaine / Mois / Année (glissant, dates réelles).",
|
||||||
|
"Le widget « Stations on air » se met à jour en quelques secondes et affiche plus de stations.",
|
||||||
|
"Le décodeur CW audio a été retiré.",
|
||||||
|
"Corrections : les boutons Antenna Genius ignoraient les clics pendant l'envoi d'une macro ; Fichier → Quitter ; et d'autres.",
|
||||||
|
"Ce résumé « Nouveautés » s'affiche désormais au premier lancement après chaque mise à jour."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// One-shot generator: reads the FIPS county CSV and emits
|
||||||
|
// internal/awardref/uscounties_gen.go. Not part of the build.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/award"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dxccForState maps the two US "states" that are separate DXCC entities.
|
||||||
|
func dxccForState(st string) int {
|
||||||
|
switch st {
|
||||||
|
case "AK":
|
||||||
|
return 6
|
||||||
|
case "HI":
|
||||||
|
return 110
|
||||||
|
default:
|
||||||
|
return 291
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// territories we exclude entirely — plus DC, which USA-CA does not count as a
|
||||||
|
// county.
|
||||||
|
var skipState = map[string]bool{"PR": true, "GU": true, "VI": true, "AS": true, "MP": true, "NA": true, "DC": true}
|
||||||
|
|
||||||
|
// excludedUSACA reports county-equivalents that the CQ USA-CA award does NOT
|
||||||
|
// count as counties: the independent cities of Virginia and Carson City (NV).
|
||||||
|
// (Baltimore MD and St. Louis MO ARE counted, so they are kept.) A contact in
|
||||||
|
// one of these counts toward a bordering county under the award rules.
|
||||||
|
func excludedUSACA(state, name string) bool {
|
||||||
|
n := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
switch state {
|
||||||
|
case "VA":
|
||||||
|
return strings.HasSuffix(n, " city")
|
||||||
|
case "NV":
|
||||||
|
return n == "carson city"
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
f, err := os.Open(os.Args[1])
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
type row struct{ code, name string; dxcc int }
|
||||||
|
var rows []row
|
||||||
|
seen := map[string]bool{}
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Scan() // header
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
parts := strings.SplitN(line, ",", 3)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(parts[1])
|
||||||
|
st := strings.TrimSpace(parts[2])
|
||||||
|
if skipState[st] || len(st) != 2 || strings.EqualFold(name, "UNITED STATES") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if excludedUSACA(st, name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// State header rows have an all-caps state NAME and state code "NA"
|
||||||
|
// (already skipped). County rows have a 2-letter state code.
|
||||||
|
code := award.USCountyKey(st, name)
|
||||||
|
if code == "" || seen[code] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[code] = true
|
||||||
|
rows = append(rows, row{code: code, name: name + ", " + st, dxcc: dxccForState(st)})
|
||||||
|
}
|
||||||
|
sort.Slice(rows, func(i, j int) bool { return rows[i].code < rows[j].code })
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("// Code generated by cmd/cntygen from FIPS county data. DO NOT EDIT.\n")
|
||||||
|
b.WriteString("package awardref\n\n")
|
||||||
|
b.WriteString("// usCounties is the US Counties (USA-CA) reference list: one entry per county\n")
|
||||||
|
b.WriteString("// in the 50 states, keyed by the canonical \"STATE,COUNTY\" match code that\n")
|
||||||
|
b.WriteString("// award.usCountyKey produces from a QSO's state + cnty fields.\n")
|
||||||
|
fmt.Fprintf(&b, "func usCounties() []Ref {\n\treturn []Ref{\n")
|
||||||
|
for _, r := range rows {
|
||||||
|
fmt.Fprintf(&b, "\t\tref(%q, %q, %d),\n", r.code, r.name, r.dxcc)
|
||||||
|
}
|
||||||
|
b.WriteString("\t}\n}\n")
|
||||||
|
|
||||||
|
if err := os.WriteFile(os.Args[2], []byte(b.String()), 0o644); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("wrote %d counties to %s\n", len(rows), os.Args[2])
|
||||||
|
}
|
||||||
+781
-266
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,37 @@ function pretty(name: string): string {
|
|||||||
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PortBtn is defined at MODULE scope on purpose. Defined inside AntGeniusPanel it
|
||||||
|
// would be a new component *type* on every render, so React would unmount and
|
||||||
|
// remount every port button each time the panel re-renders — harmless when that's
|
||||||
|
// rare, but with the CW decoder running the parent re-renders many times a second
|
||||||
|
// and the buttons were being torn down mid-click (mousedown and mouseup landing on
|
||||||
|
// different element instances), so antenna changes silently did nothing.
|
||||||
|
function PortBtn({ port, index, active, tx, onActivate, t }: {
|
||||||
|
port: 1 | 2; index: number; active: boolean; tx: boolean;
|
||||||
|
onActivate: (port: number, antenna: number) => void;
|
||||||
|
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||||
|
}) {
|
||||||
|
const letter = port === 1 ? 'A' : 'B';
|
||||||
|
const cls = tx
|
||||||
|
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||||
|
: active
|
||||||
|
? (port === 1
|
||||||
|
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||||
|
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||||
|
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onActivate(port, active ? 0 : index)}
|
||||||
|
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
||||||
|
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
||||||
// match the app's light theme with soft gradients + glows. Each antenna row has
|
// match the app's light theme with soft gradients + glows. Each antenna row has
|
||||||
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
||||||
@@ -61,27 +92,6 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
|||||||
if (filtered.length > 0) list = filtered;
|
if (filtered.length > 0) list = filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
|
||||||
const letter = port === 1 ? 'A' : 'B';
|
|
||||||
const cls = tx
|
|
||||||
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
|
||||||
: active
|
|
||||||
? (port === 1
|
|
||||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
|
||||||
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
|
||||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onActivate(port, active ? 0 : index)}
|
|
||||||
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
|
||||||
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
|
||||||
>
|
|
||||||
{letter}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||||
@@ -124,11 +134,11 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
|||||||
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||||
return (
|
return (
|
||||||
<div key={a.index} className="flex items-center gap-1.5">
|
<div key={a.index} className="flex items-center gap-1.5">
|
||||||
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
|
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} onActivate={onActivate} t={t} />
|
||||||
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
|
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
|
||||||
{pretty(a.name)}
|
{pretty(a.name)}
|
||||||
</div>
|
</div>
|
||||||
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
|
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} onActivate={onActivate} t={t} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen } from 'lucide-react';
|
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle } from 'lucide-react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
ListCountries, DXCCForCountry, DXCCName,
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||||
|
ExportAwardForCatalog,
|
||||||
|
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
// Above this many references the editor stops loading the whole list and
|
// Above this many references the editor stops loading the whole list and
|
||||||
@@ -32,11 +34,11 @@ export type AwardDef = {
|
|||||||
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; pattern: string;
|
||||||
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
|
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[];
|
||||||
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
||||||
total: number; builtin?: boolean;
|
total: number; builtin?: boolean; version?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AwardOrRule = {
|
type AwardOrRule = {
|
||||||
@@ -154,6 +156,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [updating, setUpdating] = useState<string | null>(null);
|
const [updating, setUpdating] = useState<string | null>(null);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
|
// Version to stamp into a "publish for catalog" export — defaults to one past
|
||||||
|
// the selected award's current version whenever the selection changes.
|
||||||
|
const [catVer, setCatVer] = useState('1');
|
||||||
|
|
||||||
// The err banner doubles as a success/notice area (export path, import counts,
|
// The err banner doubles as a success/notice area (export path, import counts,
|
||||||
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
|
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
|
||||||
@@ -186,6 +191,15 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
GetCatalogCodes().then((c: any) => setCatalogCodes(((c ?? []) as string[]).map((s) => s.toUpperCase()))).catch(() => {});
|
GetCatalogCodes().then((c: any) => setCatalogCodes(((c ?? []) as string[]).map((s) => s.toUpperCase()))).catch(() => {});
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// Shipped fixes we did NOT apply, because this award carries the operator's own
|
||||||
|
// changes and we will not overwrite those behind their back. Offered, not forced.
|
||||||
|
type AwardUpdate = { code: string; name: string; from: number; to: number };
|
||||||
|
const [updates, setUpdates] = useState<AwardUpdate[]>([]);
|
||||||
|
const loadUpdates = useCallback(() => {
|
||||||
|
GetAwardUpdates().then((u: any) => setUpdates(Array.isArray(u) ? u : [])).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { if (open) loadUpdates(); }, [open, loadUpdates]);
|
||||||
|
|
||||||
// Pending import awaiting the operator's decision on the awards that collide.
|
// Pending import awaiting the operator's decision on the awards that collide.
|
||||||
type ImportEntry = { code: string; name: string; references: number; exists: boolean; mine_name: string; mine_refs: number; protected: boolean };
|
type ImportEntry = { code: string; name: string; references: number; exists: boolean; mine_name: string; mine_refs: number; protected: boolean };
|
||||||
type ImportPreview = { path: string; awards: ImportEntry[] };
|
type ImportPreview = { path: string; awards: ImportEntry[] };
|
||||||
@@ -201,6 +215,58 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
}, [importPreview]);
|
}, [importPreview]);
|
||||||
|
|
||||||
const cur = defs[sel];
|
const cur = defs[sel];
|
||||||
|
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
|
||||||
|
useEffect(() => { setCatVer(String((cur?.version ?? 0) + 1)); }, [cur?.code]);
|
||||||
|
|
||||||
|
// ── Award tester: run the award's rules against a real QSO and show every step.
|
||||||
|
type Rejected = { candidate: string; reason: string };
|
||||||
|
type Step = {
|
||||||
|
rule: string; field: string; match_by?: string; exact?: boolean; pattern?: string;
|
||||||
|
field_value?: string; candidates?: string[]; kept?: string[]; rejected?: Rejected[];
|
||||||
|
skipped?: boolean; error?: string;
|
||||||
|
};
|
||||||
|
type Explanation = {
|
||||||
|
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
|
||||||
|
ref_count: number; steps: Step[]; manual?: string[]; result: string[];
|
||||||
|
};
|
||||||
|
type TestRow = { qso: any; explanation: Explanation };
|
||||||
|
const [testCall, setTestCall] = useState('');
|
||||||
|
const [testRows, setTestRows] = useState<TestRow[] | null>(null);
|
||||||
|
const [testErr, setTestErr] = useState('');
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const runTest = async () => {
|
||||||
|
if (!cur) return;
|
||||||
|
setTesting(true); setTestErr(''); setTestRows(null);
|
||||||
|
try {
|
||||||
|
const r = await ExplainAward(cur.code, testCall);
|
||||||
|
setTestRows((Array.isArray(r) ? r : []) as TestRow[]);
|
||||||
|
} catch (e: any) {
|
||||||
|
setTestErr(String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// The tester reads the SAVED award, not the unsaved edits in this dialog — so say
|
||||||
|
// so, rather than let the operator test a rule they only think they applied.
|
||||||
|
useEffect(() => { setTestRows(null); setTestErr(''); }, [sel]);
|
||||||
|
|
||||||
|
// Several QSOs with the same station usually trace IDENTICALLY, and twenty copies
|
||||||
|
// of the same trace is noise. But they don't always: scope is judged per QSO
|
||||||
|
// (band, mode, date — FFMA's 1983 cut-off), a manual override lives ON a QSO, and
|
||||||
|
// two contacts can even hold different QTHs. That divergence is often the actual
|
||||||
|
// answer ("why does my 2019 QSO count and my 2024 one not?"), so we group by
|
||||||
|
// trace instead of dropping it: one card per distinct outcome, with its count.
|
||||||
|
const testGroups = useMemo(() => {
|
||||||
|
if (!testRows) return null;
|
||||||
|
const groups: { key: string; rows: TestRow[] }[] = [];
|
||||||
|
for (const row of testRows) {
|
||||||
|
const key = JSON.stringify(row.explanation);
|
||||||
|
const g = groups.find((x) => x.key === key);
|
||||||
|
if (g) g.rows.push(row);
|
||||||
|
else groups.push({ key, rows: [row] });
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [testRows]);
|
||||||
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
||||||
const toggleIn = (key: keyof AwardDef, v: string) => {
|
const toggleIn = (key: keyof AwardDef, v: string) => {
|
||||||
const arr = ((cur?.[key] as string[]) ?? []);
|
const arr = ((cur?.[key] as string[]) ?? []);
|
||||||
@@ -238,6 +304,19 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
||||||
|
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
||||||
|
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
||||||
|
async function exportForCatalog() {
|
||||||
|
setErr('');
|
||||||
|
if (!cur) return;
|
||||||
|
const v = Math.trunc(Number(catVer));
|
||||||
|
if (!Number.isFinite(v) || v < 1) { setErr(t('awed.catalogBadVersion')); return; }
|
||||||
|
try {
|
||||||
|
const p = await ExportAwardForCatalog(cur.code.trim().toUpperCase(), v);
|
||||||
|
if (p) setErr(t('awed.catalogExportedTo', { path: p }));
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
// Import: LOOK FIRST, then ask.
|
// Import: LOOK FIRST, then ask.
|
||||||
//
|
//
|
||||||
// This used to merge by code with "imported wins", silently — import a WAPC
|
// This used to merge by code with "imported wins", silently — import a WAPC
|
||||||
@@ -291,7 +370,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||||
<DialogHeader className="px-5 py-3 border-b">
|
<DialogHeader className="px-5 py-3 border-b">
|
||||||
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -312,6 +391,10 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
// has been exported. That deserves to be visible at a glance, not
|
// has been exported. That deserves to be visible at a glance, not
|
||||||
// discovered the hard way.
|
// discovered the hard way.
|
||||||
const onlyHere = !catalogCodes.includes((d.code ?? '').toUpperCase());
|
const onlyHere = !catalogCodes.includes((d.code ?? '').toUpperCase());
|
||||||
|
// A pending update is only reachable from the award's own banner, so
|
||||||
|
// the list has to say which award to open — otherwise the fix waits
|
||||||
|
// behind a click nobody knows to make.
|
||||||
|
const hasUpdate = updates.some((u) => (u.code ?? '').toUpperCase() === (d.code ?? '').toUpperCase());
|
||||||
return (
|
return (
|
||||||
<button key={i} onClick={() => setSel(i)}
|
<button key={i} onClick={() => setSel(i)}
|
||||||
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
||||||
@@ -319,6 +402,11 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
|
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
|
||||||
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
||||||
<span className="text-muted-foreground truncate">{d.name}</span>
|
<span className="text-muted-foreground truncate">{d.name}</span>
|
||||||
|
{hasUpdate && (
|
||||||
|
<span className="ml-auto shrink-0" title={t('awed.updateAvailable')}>
|
||||||
|
<ArrowUpCircle className="size-3.5 text-info" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{onlyHere && (
|
{onlyHere && (
|
||||||
<span className="ml-auto shrink-0 px-1 rounded border border-warning-border bg-warning-muted text-warning-muted-foreground text-[9px] font-semibold uppercase tracking-wide"
|
<span className="ml-auto shrink-0 px-1 rounded border border-warning-border bg-warning-muted text-warning-muted-foreground text-[9px] font-semibold uppercase tracking-wide"
|
||||||
title={t('awed.onlyHereTip')}>
|
title={t('awed.onlyHereTip')}>
|
||||||
@@ -337,6 +425,37 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
{/* Right: tabbed editor for selected award */}
|
{/* Right: tabbed editor for selected award */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||||
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
||||||
|
{/* A fix shipped for an award this operator has customised. We did NOT
|
||||||
|
apply it — that would destroy their work — so we offer it, and say
|
||||||
|
plainly what accepting costs. */}
|
||||||
|
{cur && selUpdate && (
|
||||||
|
<div className="mx-4 mt-3 rounded border border-info/40 bg-info/10 px-3 py-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ArrowUpCircle className="size-4 text-info shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium">{t('awed.updateAvailable')}</div>
|
||||||
|
<div className="text-muted-foreground">{t('awed.updateOverwrites')}</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="h-7 px-2 text-[11px]"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await ApplyAwardUpdate(cur.code);
|
||||||
|
// The backend rewrote this award (and its references) — pull the
|
||||||
|
// new state back, or the editor would keep showing, and on the
|
||||||
|
// next Save re-persist, the definition we just replaced.
|
||||||
|
setDefs(((await GetAwardDefs()) ?? []) as any);
|
||||||
|
loadMeta();
|
||||||
|
loadUpdates();
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}}>{t('awed.updateApply')}</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-[11px]"
|
||||||
|
onClick={async () => {
|
||||||
|
try { await DismissAwardUpdate(cur.code); loadUpdates(); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}}>{t('awed.updateKeepMine')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!cur ? (
|
{!cur ? (
|
||||||
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
|
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -346,6 +465,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
||||||
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
|
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
|
||||||
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
|
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="test">{t('awed.tabTest')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
@@ -355,14 +475,12 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
||||||
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
|
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
|
||||||
{/* "Built-in" is what you tick before dropping an award into the
|
{/* No "Built-in" checkbox: an award OpsLog ships IS built-in, and
|
||||||
shipped catalog. Leave it off and "Reset to defaults" DELETES
|
the catalog derives that on load. Asking the author to tick a
|
||||||
the award on the user's machine — even though you shipped it.
|
box to declare it would be one more step nobody can guess —
|
||||||
Editing the JSON by hand to fix that is exactly what we're
|
forget it and the award silently misses every future catalog
|
||||||
avoiding here. */}
|
correction. "Protected" stays: whether an award can be deleted
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.builtinTip')}>
|
IS a real choice. */}
|
||||||
<Checkbox checked={!!cur.builtin} onCheckedChange={(c) => patch({ builtin: !!c })} /> {t('awed.builtin')}
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.protectedTip')}>
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.protectedTip')}>
|
||||||
<Checkbox checked={!!cur.protected} onCheckedChange={(c) => patch({ protected: !!c })} /> {t('awed.protectedFlag')}
|
<Checkbox checked={!!cur.protected} onCheckedChange={(c) => patch({ protected: !!c })} /> {t('awed.protectedFlag')}
|
||||||
</label>
|
</label>
|
||||||
@@ -407,7 +525,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
}</SelectContent>
|
}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field2>
|
</Field2>
|
||||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> {t('awed.allowMultiple')}</label>
|
{/* No "allow multiple references" switch: a QSO always counts for
|
||||||
|
every reference its field holds (an n-fer POTA activation, a
|
||||||
|
VUCC grid-line contact). The old checkbox was read by nothing. */}
|
||||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> {t('awed.dynamicRefs')}</label>
|
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> {t('awed.dynamicRefs')}</label>
|
||||||
|
|
||||||
<div className="border-t pt-2.5 mt-1 space-y-2.5">
|
<div className="border-t pt-2.5 mt-1 space-y-2.5">
|
||||||
@@ -494,8 +614,105 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Field2 label={t('awed.grantCodes')}><Input className="h-8" value={cur.grant_codes ?? ''} onChange={(e) => patch({ grant_codes: e.target.value })} /></Field2>
|
{/* "Grant codes" and "export credit_granted" used to live here. No
|
||||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.export_credit_granted} onCheckedChange={(c) => patch({ export_credit_granted: !!c })} /> {t('awed.exportCreditGranted')}</label>
|
ADIF export has ever written CREDIT_GRANTED, so both controls
|
||||||
|
did nothing at all. The stored values are kept (see award.Def);
|
||||||
|
put the controls back when the export is actually wired up. */}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── Test ──
|
||||||
|
Runs the SAVED award against a real QSO and shows every rule:
|
||||||
|
what it scanned, what it produced, and why each candidate was
|
||||||
|
rejected. An award that matches nothing used to be a black box. */}
|
||||||
|
<TabsContent value="test" className="mt-0 space-y-3">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">{t('awed.testCallsign')}</Label>
|
||||||
|
<Input className="h-8 w-40 font-mono uppercase" value={testCall} placeholder="I2IFT"
|
||||||
|
onChange={(e) => setTestCall(e.target.value.toUpperCase())}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') runTest(); }} />
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="h-8" onClick={runTest} disabled={testing || !testCall.trim()}>
|
||||||
|
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Search className="size-3.5 mr-1" />}
|
||||||
|
{t('awed.testRun')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground pb-1">{t('awed.testSavedOnly')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testErr && <div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5">{testErr}</div>}
|
||||||
|
|
||||||
|
{testGroups?.map((g, ri) => {
|
||||||
|
const row = g.rows[0];
|
||||||
|
const ex = row.explanation;
|
||||||
|
const matched = (ex.result ?? []).length > 0;
|
||||||
|
const qsoLabel = (q: any) => `${String(q?.qso_date ?? '').slice(0, 10)} · ${q?.band} · ${q?.mode}`;
|
||||||
|
return (
|
||||||
|
<div key={ri} className="rounded border border-border overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs border-b border-border">
|
||||||
|
<span className="font-mono font-semibold">{row.qso?.callsign}</span>
|
||||||
|
<span className="text-muted-foreground font-mono">{qsoLabel(row.qso)}</span>
|
||||||
|
{g.rows.length > 1 && (
|
||||||
|
<span className="text-muted-foreground" title={g.rows.slice(1).map((r) => qsoLabel(r.qso)).join('\n')}>
|
||||||
|
{t('awed.testSameAs', { n: String(g.rows.length - 1) })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn('ml-auto px-1.5 rounded text-[10px] font-semibold uppercase tracking-wide',
|
||||||
|
matched ? 'bg-success/15 text-success' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||||
|
{matched ? (ex.result ?? []).join(', ') : t('awed.testNoMatch')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!ex.in_scope ? (
|
||||||
|
<div className="px-3 py-2 text-xs">
|
||||||
|
<span className="font-medium">{t('awed.testOutOfScope')}</span>{' '}
|
||||||
|
<span className="text-muted-foreground">{ex.scope_error}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border/50">
|
||||||
|
{(ex.steps ?? []).map((s, si) => (
|
||||||
|
<div key={si} className={cn('px-3 py-2 text-xs', s.skipped && 'opacity-50')}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="font-semibold uppercase text-[10px] tracking-wide">{s.rule}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{s.field}{s.match_by ? ` / ${s.match_by}` : ''}{s.exact ? ` / ${t('awed.exact')}` : ''}
|
||||||
|
</span>
|
||||||
|
{s.skipped && <span className="text-muted-foreground italic">— {t('awed.testSkipped')}</span>}
|
||||||
|
{s.error && <span className="text-destructive">— {s.error}</span>}
|
||||||
|
</div>
|
||||||
|
{!s.skipped && !s.error && (
|
||||||
|
<div className="mt-1 space-y-0.5 pl-3 border-l-2 border-border">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">{t('awed.testFieldValue')}: </span>
|
||||||
|
{s.field_value
|
||||||
|
? <span className="font-mono">{s.field_value}</span>
|
||||||
|
: <span className="italic text-muted-foreground">{t('awed.testEmptyField')}</span>}
|
||||||
|
</div>
|
||||||
|
{(s.kept ?? []).map((k) => (
|
||||||
|
<div key={k} className="text-success font-mono">✓ {k}</div>
|
||||||
|
))}
|
||||||
|
{(s.rejected ?? []).map((r, i2) => (
|
||||||
|
<div key={i2} className="font-mono text-muted-foreground">
|
||||||
|
✗ {r.candidate} <span className="font-sans">— {r.reason}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!(s.kept ?? []).length && !(s.rejected ?? []).length && s.field_value && (
|
||||||
|
<div className="italic text-muted-foreground">{t('awed.testNoCandidate')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(ex.manual ?? []).length > 0 && (
|
||||||
|
<div className="px-3 py-2 text-xs">
|
||||||
|
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
|
||||||
|
<span className="font-mono text-success">{(ex.manual ?? []).join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ── References ── */}
|
{/* ── References ── */}
|
||||||
@@ -527,6 +744,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
title={t('awed.awardsFolderTip')}>
|
title={t('awed.awardsFolderTip')}>
|
||||||
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
|
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Publish the selected award to the catalog: stamp a version and write a
|
||||||
|
file to paste over internal/award/catalog/<code>.json, so a release
|
||||||
|
ships your change to the whole team. */}
|
||||||
|
{cur && (
|
||||||
|
<div className="flex items-center gap-1" title={t('awed.catalogPublishTip')}>
|
||||||
|
<input type="number" min={1} value={catVer} onChange={(e) => setCatVer(e.target.value)}
|
||||||
|
className="h-8 w-14 rounded border border-input bg-background px-1.5 text-xs font-mono" />
|
||||||
|
<Button variant="outline" onClick={exportForCatalog}>
|
||||||
|
<Download className="size-3.5 mr-1" /> {t('awed.catalogPublish')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
||||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
||||||
@@ -744,26 +973,18 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
<Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
<Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{/* One per row: side by side, each half spends 120px of its width on
|
||||||
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
the label column and the input is left too narrow to read a group
|
||||||
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
name ("Basilicata" → "Basili"). */}
|
||||||
</div>
|
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
||||||
|
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
||||||
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
||||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2>
|
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
{/* Score / Bonus were here. Nothing computes an award score, so both
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
boxes were pure decoration. The columns stay in the database — a
|
||||||
<Label className="text-xs text-muted-foreground">{t('awed.score')}</Label>
|
third-party list may carry the values — but they are not offered
|
||||||
<Input type="number" className="h-8 font-mono w-full" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} />
|
for editing until something actually reads them. */}
|
||||||
</div>
|
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
|
||||||
<Label className="text-xs text-muted-foreground">{t('awed.bonus')}</Label>
|
|
||||||
<Input type="number" className="h-8 font-mono w-full" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
|
||||||
<Label className="text-xs text-muted-foreground">{t('awed.grid')}</Label>
|
|
||||||
<Input className="h-8 font-mono w-full" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_name', label: 'bulk.fMyName', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_arrl_sect', label: 'bulk.fMyArrlSect', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_darc_dok', label: 'bulk.fMyDarcDok', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_vucc_grids', label: 'bulk.fMyVuccGrids', group: 'My station', kind: 'text', upper: true },
|
||||||
// Contest
|
// Contest
|
||||||
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
|
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
|
||||||
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
|
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
|
||||||
@@ -60,7 +64,9 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'prop_mode', label: 'bulk.fPropMode', group: 'Propagation', kind: 'text', upper: true },
|
{ id: 'prop_mode', label: 'bulk.fPropMode', group: 'Propagation', kind: 'text', upper: true },
|
||||||
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
|
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
|
||||||
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
||||||
// Contacted station (activation refs / SIG)
|
// Contacted station (location / activation refs / SIG)
|
||||||
|
{ id: 'state', label: 'bulk.fState', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
|
{ id: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' },
|
||||||
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
{ id: 'sota_ref', label: 'bulk.fSotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
{ id: 'sota_ref', label: 'bulk.fSotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
{ id: 'wwff_ref', label: 'bulk.fWwffRef', group: 'Contacted station', kind: 'text', upper: true },
|
{ id: 'wwff_ref', label: 'bulk.fWwffRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export type SpotStatusEntry = {
|
|||||||
country?: string;
|
country?: string;
|
||||||
continent?: string;
|
continent?: string;
|
||||||
worked_call?: boolean;
|
worked_call?: boolean;
|
||||||
|
new_county?: boolean;
|
||||||
|
new_pota?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -101,13 +103,43 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
|||||||
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
||||||
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
||||||
// there's nothing notable to show.
|
// there's nothing notable to show.
|
||||||
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
type Badge = { text: string; fg: string; bg: string; bd: string };
|
||||||
|
|
||||||
|
// tok builds a badge from a semantic status token (success/warning/caution/
|
||||||
|
// danger/info/neutral) so the colours adapt to every theme via CSS variables,
|
||||||
|
// instead of the hard-coded light-theme pastels that looked wrong in dark mode.
|
||||||
|
function tok(name: string, text: string): Badge {
|
||||||
|
if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' };
|
||||||
|
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
|
||||||
|
// Status badges) when the slot is notable, instead of flooding the whole cell with
|
||||||
|
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
|
||||||
|
// null → plain text, no pill.
|
||||||
|
function cellChip(value: any, name: string | null): any {
|
||||||
|
const txt = value === undefined || value === null || value === '' ? '' : String(value);
|
||||||
|
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||||
|
// Inherit the column's font size (no fixed 9px / height) so a pill around a
|
||||||
|
// callsign stays the same size as the plain callsigns next to it — just tinted
|
||||||
|
// and rounded, not shrunk.
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', lineHeight: 1.1,
|
||||||
|
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
|
||||||
|
border: `1px solid var(--${name}-border)`, fontWeight: 700,
|
||||||
|
padding: '1px 6px', borderRadius: 999, whiteSpace: 'nowrap',
|
||||||
|
}}>{txt}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
|
||||||
switch (s?.status) {
|
switch (s?.status) {
|
||||||
case 'new': return { text: t('clg2.newDxcc'), fg: '#be123c', bg: '#ffe4e6' };
|
case 'new': return tok('danger', t('clg2.newDxcc'));
|
||||||
case 'new-band': return { text: t('clg2.newBand'), fg: '#92400e', bg: '#fde68a' };
|
case 'new-band': return tok('warning', t('clg2.newBand'));
|
||||||
case 'new-mode': return { text: t('clg2.newMode'), fg: '#854d0e', bg: '#fef08a' };
|
case 'new-mode': return tok('caution', t('clg2.newMode'));
|
||||||
case 'new-slot': return { text: t('clg2.newSlot'), fg: '#854d0e', bg: '#fef08a' };
|
case 'new-slot': return tok('caution', t('clg2.newSlot'));
|
||||||
default: return s?.worked_call ? { text: t('clg2.wkdCall'), fg: '#0369a1', bg: '#e0f2fe' } : null;
|
default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,22 +149,22 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
sort: 'desc',
|
sort: 'desc',
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
||||||
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
// Only STATUS calls get a colour: new DXCC entity → filled cell (no padded
|
// NEW DXCC → a danger pill around the call, consistent with the NEW BAND /
|
||||||
// pill, so calls stay aligned), worked-call → blue. A plain spot inherits the
|
// NEW MODE pills (same look, same tokens). Worked-call stays blue bold text;
|
||||||
// theme's normal text colour (var(--foreground)) so callsigns blend in with
|
// a plain spot inherits the theme's normal text colour so callsigns blend in
|
||||||
// the rest of the row across every theme instead of always shouting orange.
|
// with the rest of the row instead of always shouting a colour.
|
||||||
cellStyle: (p: any): any => {
|
cellRenderer: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
if (s?.status === 'new') return { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 };
|
if (s?.status === 'new') return cellChip(p.value, 'danger');
|
||||||
if (s?.worked_call) return { color: '#0369a1', fontWeight: 700 };
|
const color = s?.worked_call ? 'var(--info)' : undefined;
|
||||||
return { fontWeight: 700 };
|
return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
|
||||||
},
|
},
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
@@ -141,26 +173,42 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
||||||
headerName: t('clg2.c.status'), width: 96, sortable: true,
|
headerName: t('clg2.c.status'), width: 120, sortable: true,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
||||||
// is obvious at the row level, not just a single coloured cell.
|
// is obvious at the row level, not just a single coloured cell. NEW COUNTY
|
||||||
|
// and NEW POTA are orthogonal, so they stack as extra badges.
|
||||||
valueGetter: (p: any) => {
|
valueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
if (s?.status === 'new') return t('clg2.newDxcc');
|
const parts: string[] = [];
|
||||||
if (s?.status === 'new-band') return t('clg2.newBand');
|
if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
|
||||||
if (s?.status === 'new-mode') return t('clg2.newMode');
|
else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
|
||||||
if (s?.status === 'new-slot') return t('clg2.newSlot');
|
else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
|
||||||
return s?.worked_call ? t('clg2.wkdCall') : '';
|
else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot'));
|
||||||
|
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
|
||||||
|
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
||||||
|
if (s?.new_pota) parts.push(t('clg2.newPota'));
|
||||||
|
return parts.join(' ');
|
||||||
},
|
},
|
||||||
cellRenderer: (p: any) => {
|
cellRenderer: (p: any) => {
|
||||||
const b = statusBadge(t, statusFor(p));
|
const s = statusFor(p);
|
||||||
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
const badges: Badge[] = [];
|
||||||
|
const b = statusBadge(t, s);
|
||||||
|
if (b) badges.push(b);
|
||||||
|
if (s?.new_county) badges.push(tok('info', t('clg2.newCounty')));
|
||||||
|
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota')));
|
||||||
|
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||||
return (
|
return (
|
||||||
<span style={{
|
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
|
||||||
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10,
|
{badges.map((bd, i) => (
|
||||||
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
<span key={i} style={{
|
||||||
}}>{b.text}</span>
|
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
|
||||||
|
backgroundColor: bd.bg, color: bd.fg, border: `1px solid ${bd.bd}`,
|
||||||
|
fontWeight: 700, fontSize: 9,
|
||||||
|
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
||||||
|
}}>{bd.text}</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
@@ -176,7 +224,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
||||||
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: '#166534' },
|
cellStyle: { color: 'var(--success)' },
|
||||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -191,10 +239,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
|
// NEW BAND for this entity → small warning pill around the band text.
|
||||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
|
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null),
|
||||||
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
|
|
||||||
: undefined),
|
|
||||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -203,16 +249,11 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||||
// Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
|
// Only NEW MODE pills the mode cell — there the mode itself is genuinely new
|
||||||
// yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
|
// for the entity. NEW SLOT means band AND mode were each worked before (just
|
||||||
cellStyle: (p: any) => {
|
// not together), so highlighting the mode cell would wrongly imply "CW is new";
|
||||||
const st = statusFor(p)?.status;
|
// that case is signalled by the Status badge alone.
|
||||||
// Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the
|
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null),
|
||||||
// Status badge text tells them apart.
|
|
||||||
if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 };
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const st = statusFor(p)?.status;
|
const st = statusFor(p)?.status;
|
||||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||||
@@ -224,7 +265,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
||||||
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
||||||
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
||||||
@@ -261,7 +302,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
|
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
|
||||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||||
]?.country ?? '',
|
]?.country ?? '',
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
|
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
|
||||||
@@ -270,31 +311,31 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
|
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
|
||||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||||
]?.continent ?? '',
|
]?.continent ?? '',
|
||||||
cellStyle: { color: '#7a6b50', fontSize: 10 },
|
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
|
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
|
||||||
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
|
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
|
||||||
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
|
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: '#9a8870', fontSize: 10 },
|
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
|
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
|
||||||
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
|
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
|
||||||
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
|
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
|
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
|||||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
|
||||||
<Mic className="size-3.5 text-primary" />
|
<Mic className="size-3.5 text-primary" />
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
|
||||||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-warning animate-pulse' : 'bg-success')} />
|
<span className={cn('size-2 rounded-full', status.playing ? 'bg-danger animate-pulse' : 'bg-success')} />
|
||||||
{status.playing && <span className="text-[10px] text-warning font-medium">tx...</span>}
|
{status.playing && <span className="text-[10px] text-danger font-medium">TX</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
||||||
<Square className="size-3" /> {t('dvkp.stop')}
|
<Square className="size-3" /> {t('dvkp.stop')}
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import {
|
|||||||
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||||
FlexMox, FlexAmpOperate,
|
FlexMox, FlexAmpOperate,
|
||||||
GetPGXLStatus, PGXLSetFanMode,
|
GetPGXLStatus, PGXLSetFanMode, GetPGXLSettings, GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel,
|
||||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||||
|
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
||||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||||
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
|
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
|
||||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { sMeterRST } from '@/lib/rst';
|
import { sMeterRST } from '@/lib/rst';
|
||||||
@@ -25,6 +27,7 @@ type FlexState = {
|
|||||||
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
|
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
|
||||||
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
|
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
|
||||||
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
|
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
|
||||||
|
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
|
||||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
||||||
wnb: boolean; wnb_level: number;
|
wnb: boolean; wnb_level: number;
|
||||||
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
|
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
|
||||||
@@ -44,6 +47,7 @@ const ZERO: FlexState = {
|
|||||||
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
|
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
|
||||||
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
|
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
|
||||||
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
|
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
|
||||||
|
rit: false, rit_freq: 0, xit: false, xit_freq: 0,
|
||||||
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
|
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
|
||||||
wnb: false, wnb_level: 0, tx_filter_low: 0, tx_filter_high: 0,
|
wnb: false, wnb_level: 0, tx_filter_low: 0, tx_filter_high: 0,
|
||||||
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
|
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
|
||||||
@@ -143,6 +147,67 @@ function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, slide
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OffsetRow — RIT / XIT: a switch plus one signed offset field you scrub with the
|
||||||
|
// wheel (or ± , or the arrow keys once focused; hold Ctrl/Shift for 100 Hz steps).
|
||||||
|
// Deliberately the same control as the Icom panel's ShiftRow, so the two rigs are
|
||||||
|
// driven identically.
|
||||||
|
//
|
||||||
|
// The offset is NOT cleared when the switch goes off: SmartSDR keeps it, so
|
||||||
|
// flipping RIT back on returns you exactly where you were, like the radio's own
|
||||||
|
// knob. Zeroing it is a separate, deliberate act — that is what the 0 button is for.
|
||||||
|
const OFFSET_MAX = 99999; // SmartSDR's limit
|
||||||
|
function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
|
||||||
|
label: string; on: boolean; onToggle: () => void; hz: number; onHz: (v: number) => void;
|
||||||
|
disabled?: boolean; title?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const step = useRef((_d: number) => {});
|
||||||
|
step.current = (d: number) => onHz(Math.max(-OFFSET_MAX, Math.min(OFFSET_MAX, (hz || 0) + d)));
|
||||||
|
|
||||||
|
// React's onWheel is passive, so preventDefault() there is ignored and the panel
|
||||||
|
// scrolls under the cursor instead of the value changing. Bind it natively.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
|
||||||
|
step.current(e.deltaY < 0 ? mag : -mag);
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onKey = (e: React.KeyboardEvent) => {
|
||||||
|
const up = e.key === 'ArrowUp' || e.key === 'ArrowRight';
|
||||||
|
const dn = e.key === 'ArrowDown' || e.key === 'ArrowLeft';
|
||||||
|
if (!up && !dn) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
|
||||||
|
step.current(up ? mag : -mag);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2" title={title}>
|
||||||
|
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent="cyan" />
|
||||||
|
<div ref={ref} data-offsetrow tabIndex={disabled || !on ? -1 : 0} onKeyDown={onKey}
|
||||||
|
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-info/50',
|
||||||
|
on && !disabled ? 'border-border bg-muted/40 cursor-ns-resize' : 'border-border/60 bg-muted/20 opacity-60')}>
|
||||||
|
<button type="button" disabled={disabled || !on} onClick={() => step.current(-10)}
|
||||||
|
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:hover:text-muted-foreground">−</button>
|
||||||
|
<span className={cn('text-sm font-mono font-bold tabular-nums', on && hz ? 'text-info' : 'text-muted-foreground')}>
|
||||||
|
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz || 0)} Hz
|
||||||
|
</span>
|
||||||
|
<button type="button" disabled={disabled || !on} onClick={() => step.current(10)}
|
||||||
|
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:hover:text-muted-foreground">+</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" disabled={disabled || !hz} onClick={() => onHz(0)}
|
||||||
|
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted disabled:opacity-30">0</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
||||||
// `display` overrides the numeric readout; `segColor` colours segments by their
|
// `display` overrides the numeric readout; `segColor` colours segments by their
|
||||||
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
||||||
@@ -198,6 +263,25 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// speMaxW is the amp's rated output, used as the power-bar full scale. Derived from
|
||||||
|
// the model string the amp reports (13K→1.3kW, 15K→1.5kW, 2K/20K→2kW).
|
||||||
|
function speMaxW(model?: string): number {
|
||||||
|
const m = (model || '').toUpperCase();
|
||||||
|
if (m.includes('20K') || m.includes('2K')) return 2000;
|
||||||
|
if (m.includes('15K')) return 1500;
|
||||||
|
return 1300; // 1.3K-FA default
|
||||||
|
}
|
||||||
|
|
||||||
|
// powerLevelLabel spells out the SPE power-level code (L/M/H) in full.
|
||||||
|
function powerLevelLabel(pl?: string): string {
|
||||||
|
switch ((pl || '').trim().toUpperCase()) {
|
||||||
|
case 'L': return 'Low';
|
||||||
|
case 'M': return 'Mid';
|
||||||
|
case 'H': return 'High';
|
||||||
|
default: return pl || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// onCWSpeed (optional): notified when the operator changes CW speed here, so the
|
// onCWSpeed (optional): notified when the operator changes CW speed here, so the
|
||||||
// host can keep the WinKeyer (which actually sends the macros) in sync.
|
// host can keep the WinKeyer (which actually sends the macros) in sync.
|
||||||
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
|
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
|
||||||
@@ -245,6 +329,29 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Configured amplifier type (pgxl / spe*), so we show the SPE control when the
|
||||||
|
// operator runs an SPE Expert instead of the PowerGenius.
|
||||||
|
const [ampType, setAmpType] = useState<string>('pgxl');
|
||||||
|
const [ampEnabled, setAmpEnabled] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = () => GetPGXLSettings().then((s: any) => { if (alive) { setAmpType(s?.type || 'pgxl'); setAmpEnabled(!!s?.enabled); } }).catch(() => {});
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 5000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
const isSPE = ampEnabled && ampType !== 'pgxl';
|
||||||
|
// SPE Expert live status (only polled when an SPE amp is configured).
|
||||||
|
const [spe, setSpe] = useState<any>({ connected: false });
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSPE) return;
|
||||||
|
let alive = true;
|
||||||
|
const tick = () => GetSPEStatus().then((s: any) => alive && setSpe(s || { connected: false })).catch(() => {});
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, [isSPE]);
|
||||||
|
|
||||||
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
||||||
hold.current[key] = Date.now() + 900;
|
hold.current[key] = Date.now() + 900;
|
||||||
setSt((p) => ({ ...p, [key]: val }));
|
setSt((p) => ({ ...p, [key]: val }));
|
||||||
@@ -253,6 +360,39 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
|
|
||||||
const off = !st.available;
|
const off = !st.available;
|
||||||
const rxOff = off || !st.rx_avail;
|
const rxOff = off || !st.rx_avail;
|
||||||
|
|
||||||
|
// Radio-style global RIT tuning: Ctrl+←/→ nudges the RIT offset (Ctrl+Shift =
|
||||||
|
// ±100 Hz) from ANYWHERE — including while the callsign/RST entry field has
|
||||||
|
// focus, which is where the operator is while working a station. Only the RIT
|
||||||
|
// offset row itself (data-offsetrow) is skipped, since it handles its own
|
||||||
|
// arrows. Text-field word navigation via Ctrl+← / → is overridden only while
|
||||||
|
// RIT is actually engaged (guarded below), which is fine mid-QSO. Requires RIT
|
||||||
|
// on and the RX available.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (!(e.ctrlKey || e.metaKey)) return;
|
||||||
|
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||||
|
const ae = document.activeElement as HTMLElement | null;
|
||||||
|
if (ae && ae.hasAttribute('data-offsetrow')) return;
|
||||||
|
if (!st.rit || rxOff) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const mag = e.shiftKey ? 100 : 10;
|
||||||
|
const next = (st.rit_freq || 0) + (e.key === 'ArrowRight' ? mag : -mag);
|
||||||
|
change('rit_freq', next, () => FlexSetRITFreq(next));
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [st.rit, st.rit_freq, rxOff]);
|
||||||
|
|
||||||
|
// Clear the RIT offset back to 0 whenever a QSO is logged (any path: Log
|
||||||
|
// button, keyer macro, WSJT-X), so the next station starts centred. Only acts
|
||||||
|
// when there's actually a non-zero offset to clear.
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('qso:logged', () => {
|
||||||
|
if (st.rit && (st.rit_freq || 0) !== 0) change('rit_freq', 0, () => FlexSetRITFreq(0));
|
||||||
|
});
|
||||||
|
return () => off();
|
||||||
|
}, [st.rit, st.rit_freq]);
|
||||||
const isCW = (st.mode || '').toUpperCase().includes('CW');
|
const isCW = (st.mode || '').toUpperCase().includes('CW');
|
||||||
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
||||||
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
||||||
@@ -454,6 +594,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{!isCW ? (
|
{!isCW ? (
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -560,6 +701,18 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* RIT / XIT — on the RECEIVE side: RIT is what you reach for while
|
||||||
|
listening (chasing a station that drifts off your transmit frequency),
|
||||||
|
so it belongs with the other things you touch mid-QSO. Both act on the
|
||||||
|
ACTIVE slice and follow slice focus like every control here. */}
|
||||||
|
<div className="space-y-1.5 pb-3 border-b border-border/60">
|
||||||
|
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
|
||||||
|
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
|
||||||
|
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
|
||||||
|
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
|
||||||
|
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
|
||||||
|
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
||||||
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
||||||
@@ -649,8 +802,68 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* External amplifier (PowerGenius XL) — only when detected. */}
|
{/* SPE Expert amplifier (serial/TCP) — shown when it's the configured amp.
|
||||||
{st.amp_available && (
|
The Flex doesn't report SPE amps, so this card is driven by OpsLog's own
|
||||||
|
SPE link rather than the Flex amplifier object. */}
|
||||||
|
{isSPE && (
|
||||||
|
<Card icon={Flame} title={`${t('flxp.amplifier')} · SPE${spe.model ? ' ' + spe.model : ''}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => SPESetOperate(!spe.operate).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
|
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */}
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||||
|
<button type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => SPESetPower(true).catch(() => {})}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||||
|
<button type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => SPESetPower(false).catch(() => {})}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||||
|
</div>
|
||||||
|
{/* Output power level: Low / Mid / High. */}
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border border-border">
|
||||||
|
{(['L', 'M', 'H'] as const).map((lvl, i) => {
|
||||||
|
const active = (spe.power_level || '').trim().toUpperCase() === lvl;
|
||||||
|
return (
|
||||||
|
<button key={lvl} type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => SPESetPowerLevel(lvl).catch(() => {})}
|
||||||
|
className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border',
|
||||||
|
active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{powerLevelLabel(lvl)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-sm', spe.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', spe.connected ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{spe.connected ? (spe.tx ? 'TX' : 'RX') : t('flxp.speOffline')}
|
||||||
|
</span>
|
||||||
|
{spe.connected && (
|
||||||
|
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||||
|
{spe.band ? `${spe.band} · ` : ''}{spe.output_w}W · SWR {Number(spe.swr_ant ?? 0).toFixed(1)} · {spe.temp_c}°C · {powerLevelLabel(spe.power_level)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{(spe.warnings || spe.alarms) && (
|
||||||
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {spe.warnings} {spe.alarms}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{spe.connected && (
|
||||||
|
<MeterBar label={t('flxp.outputPower')} value={Number(spe.output_w) || 0} unit="W"
|
||||||
|
lo={0} hi={speMaxW(spe.model)}
|
||||||
|
display={`${Number(spe.output_w) || 0} W`}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* External amplifier (PowerGenius XL) — only when the Flex reports one AND
|
||||||
|
PowerGenius is the selected amp type. Running an SPE Expert hides this
|
||||||
|
Flex-reported card so the two amps don't both show. */}
|
||||||
|
{st.amp_available && !isSPE && (
|
||||||
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button type="button" disabled={off}
|
<button type="button" disabled={off}
|
||||||
@@ -665,7 +878,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
||||||
PowerGenius). The dot shows the direct-connection state; the
|
PowerGenius). The dot shows the direct-connection state; the
|
||||||
selector is disabled until connected (hover it for the error). */}
|
selector is disabled until connected (hover it for the error). */}
|
||||||
{(pg.host || pg.connected) && (
|
{ampType === 'pgxl' && (pg.host || pg.connected) && (
|
||||||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||||||
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||||
<span className="text-muted-foreground">{t('flxp.fan')}</span>
|
<span className="text-muted-foreground">{t('flxp.fan')}</span>
|
||||||
@@ -701,7 +914,17 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
|
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
|
||||||
}
|
}
|
||||||
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={acc} />;
|
// Drain current (ID): the PGXL reports a full-scale far too small
|
||||||
|
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
|
||||||
|
// value itself is fine — only the bar's range was wrong. Give it a
|
||||||
|
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
|
||||||
|
// only override an unusable reported range, not a sane one.
|
||||||
|
let lo = m.lo, hi = m.hi;
|
||||||
|
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
||||||
|
lo = 0;
|
||||||
|
hi = m.hi >= 25 ? m.hi : 25;
|
||||||
|
}
|
||||||
|
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ function loadBasemap(): BasemapKey {
|
|||||||
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
|
||||||
|
// place-name overlay. updateWhenIdle/keepBuffer keep the number of live tiles
|
||||||
|
// down: satellite loads TWO tile layers, so its tile count — and the composited
|
||||||
|
// layers WebView2 has to hold — is double every other basemap's.
|
||||||
|
function addBasemap(
|
||||||
|
m: L.Map,
|
||||||
|
key: BasemapKey,
|
||||||
|
base: React.MutableRefObject<L.TileLayer | null>,
|
||||||
|
labels: React.MutableRefObject<L.TileLayer | null>,
|
||||||
|
) {
|
||||||
|
if (base.current) { m.removeLayer(base.current); base.current = null; }
|
||||||
|
if (labels.current) { m.removeLayer(labels.current); labels.current = null; }
|
||||||
|
const bm = BASEMAPS[key];
|
||||||
|
const opts: L.TileLayerOptions = { maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1 };
|
||||||
|
base.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||||
|
if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
||||||
|
}
|
||||||
|
|
||||||
function dot(color: string): L.DivIcon {
|
function dot(color: string): L.DivIcon {
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
@@ -103,11 +121,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
// One-time map creation.
|
// One-time map creation.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (worldRef.current && !worldMap.current) {
|
if (worldRef.current && !worldMap.current) {
|
||||||
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
|
// preferCanvas: the beam lobe is a dense FAN of translucent radials — up to
|
||||||
|
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
|
||||||
|
// composited paths re-rasterised on every pan, zoom and redraw, which is
|
||||||
|
// enough to blow WebView2's raster budget and leave the window painting in
|
||||||
|
// patches. On canvas it is a single layer.
|
||||||
|
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true })
|
||||||
.setView([20, 0], 1);
|
.setView([20, 0], 1);
|
||||||
const bm = BASEMAPS[basemap];
|
addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||||
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
|
|
||||||
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
|
|
||||||
worldOverlay.current = L.layerGroup().addTo(m);
|
worldOverlay.current = L.layerGroup().addTo(m);
|
||||||
worldMap.current = m;
|
worldMap.current = m;
|
||||||
const sv = loadMapView();
|
const sv = loadMapView();
|
||||||
@@ -115,7 +136,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
||||||
}
|
}
|
||||||
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
||||||
return () => window.clearTimeout(t);
|
// Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on
|
||||||
|
// every overlay redraw (as before) forced a full repaint of the map each time
|
||||||
|
// the rotor moved a degree.
|
||||||
|
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
|
||||||
|
if (worldRef.current) ro.observe(worldRef.current);
|
||||||
|
return () => { window.clearTimeout(t); ro.disconnect(); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Swap the basemap (and its optional place-name overlay) when the operator
|
// Swap the basemap (and its optional place-name overlay) when the operator
|
||||||
@@ -123,12 +149,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
// overlayPane, always above any tile layer, so nothing to re-stack there.
|
// overlayPane, always above any tile layer, so nothing to re-stack there.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const m = worldMap.current;
|
const m = worldMap.current;
|
||||||
if (!m) return;
|
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||||
if (baseLayer.current) { m.removeLayer(baseLayer.current); baseLayer.current = null; }
|
|
||||||
if (labelsLayer.current) { m.removeLayer(labelsLayer.current); labelsLayer.current = null; }
|
|
||||||
const bm = BASEMAPS[basemap];
|
|
||||||
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
|
|
||||||
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
|
|
||||||
}, [basemap]);
|
}, [basemap]);
|
||||||
|
|
||||||
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||||
@@ -224,9 +245,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
wm.setView([from.lat, from.lon], 3);
|
wm.setView([from.lat, from.lon], 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTimeout(() => { wm.invalidateSize(); }, 0);
|
// No invalidateSize() here — a ResizeObserver handles the only case that needs
|
||||||
|
// it. Forcing a full map repaint on every redraw is what made a moving rotor
|
||||||
|
// thrash the compositor.
|
||||||
|
// Headings are rounded in the deps: a rotor reports a jittering float, and a
|
||||||
|
// tenth of a degree is invisible on a 5500 km lobe but rebuilds every polyline.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom, zoomSignal]);
|
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth,
|
||||||
|
boomAzimuth == null ? null : Math.round(boomAzimuth), autoZoom, zoomSignal]);
|
||||||
|
|
||||||
const path = pathBetween(fromGrid, toGrid);
|
const path = pathBetween(fromGrid, toGrid);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
import {
|
import {
|
||||||
@@ -59,13 +60,13 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
|
|
||||||
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
||||||
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
||||||
|
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
|
||||||
|
|
||||||
// Add/edit-contact dialog.
|
// Add/edit-contact dialog.
|
||||||
const [contactOpen, setContactOpen] = useState(false);
|
const [contactOpen, setContactOpen] = useState(false);
|
||||||
const [contact, setContact] = useState<Station>(emptyStation());
|
const [contact, setContact] = useState<Station>(emptyStation());
|
||||||
const [looking, setLooking] = useState(false);
|
const [looking, setLooking] = useState(false);
|
||||||
|
|
||||||
const activeGrid = useRef<any>(null);
|
|
||||||
const rosterGrid = useRef<any>(null);
|
const rosterGrid = useRef<any>(null);
|
||||||
|
|
||||||
// Worked-before for the clicked station (see if/when we contacted it before).
|
// Worked-before for the clicked station (see if/when we contacted it before).
|
||||||
@@ -230,18 +231,6 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
|
|
||||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
|
||||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
|
||||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
|
||||||
{ headerName: t('ncp.colTimeOn'), valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
|
|
||||||
{ headerName: t('ncp.colBand'), field: 'band', width: 70, cellClass: 'font-mono' },
|
|
||||||
{ headerName: t('ncp.colMode'), field: 'mode', width: 70, cellClass: 'font-mono' },
|
|
||||||
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
|
|
||||||
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
|
|
||||||
{ headerName: t('ncp.colComment'), field: 'comment', flex: 1.5 },
|
|
||||||
], [t]);
|
|
||||||
|
|
||||||
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||||
@@ -292,26 +281,20 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
{t('ncp.onAirActive')}
|
{t('ncp.onAirActive')}
|
||||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div className="flex flex-col min-h-0 flex-1">
|
||||||
<div style={{ position: 'absolute', inset: 0 }}>
|
<RecentQSOsGrid
|
||||||
<AgGridReact<QSOForm>
|
rows={active}
|
||||||
ref={activeGrid}
|
total={active.length}
|
||||||
theme={hamlogTheme}
|
storageKey="net.onair"
|
||||||
rowData={active}
|
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
||||||
columnDefs={activeCols}
|
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
||||||
defaultColDef={defaultColDef}
|
onRowSelected={setSelectedActiveIds}
|
||||||
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
/>
|
||||||
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
|
|
||||||
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
|
||||||
animateRows={false}
|
|
||||||
getRowId={(p) => String((p.data as any).id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{isOpen && active.length > 0 && (
|
{isOpen && active.length > 0 && (
|
||||||
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||||
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
|
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
|
||||||
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,7 +318,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 overflow-auto">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
{!wbCall ? (
|
{!wbCall ? (
|
||||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
|
||||||
) : wbBusy ? (
|
) : wbBusy ? (
|
||||||
@@ -343,30 +326,11 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
) : !wb || wb.count === 0 ? (
|
) : !wb || wb.count === 0 ? (
|
||||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div>
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs">
|
<RecentQSOsGrid
|
||||||
<thead className="sticky top-0 bg-muted/30 text-muted-foreground">
|
rows={(wb.entries ?? []) as any}
|
||||||
<tr className="text-left">
|
total={wb.count}
|
||||||
<th className="px-3 py-1 font-semibold">{t('ncp.colDate')}</th>
|
storageKey="net.wb"
|
||||||
<th className="px-2 py-1 font-semibold">{t('ncp.colTimeOn')}</th>
|
/>
|
||||||
<th className="px-2 py-1 font-semibold">{t('ncp.colBand')}</th>
|
|
||||||
<th className="px-2 py-1 font-semibold">{t('ncp.colMode')}</th>
|
|
||||||
<th className="px-2 py-1 font-semibold">RST</th>
|
|
||||||
<th className="px-2 py-1 font-semibold">{t('ncp.colComment')}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{((wb.entries ?? []) as any[]).map((q, i) => (
|
|
||||||
<tr key={q.id ?? i} className="border-t border-border/20 hover:bg-muted/30">
|
|
||||||
<td className="px-3 py-1 font-mono">{String(q.qso_date ?? '').slice(0, 10)}</td>
|
|
||||||
<td className="px-2 py-1 font-mono">{String(q.time_on ?? '').slice(0, 5)}</td>
|
|
||||||
<td className="px-2 py-1">{q.band}</td>
|
|
||||||
<td className="px-2 py-1">{q.mode}</td>
|
|
||||||
<td className="px-2 py-1 font-mono">{(q.rst_sent ?? '')}/{(q.rst_rcvd ?? '')}</td>
|
|
||||||
<td className="px-2 py-1 truncate max-w-[360px]">{q.comment}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
|
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
@@ -32,7 +32,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
|
|||||||
|
|
||||||
type Confirmation = {
|
type Confirmation = {
|
||||||
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
||||||
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
|
new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERVICES = [
|
const SERVICES = [
|
||||||
@@ -208,18 +208,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
||||||
|
|
||||||
|
// Worked/confirmed tallies. Slots are counted by mode CLASS (PH/CW/DIGI) — the
|
||||||
|
// raw-mode granularity (RTTY ≠ FT8) stays only in the cluster/matrix new-slot flag.
|
||||||
|
const [stats, setStats] = useState<any>(
|
||||||
|
{ slots_worked: 0, slots_confirmed: 0, dxcc_worked: 0, dxcc_confirmed: 0, ph_worked: 0, ph_confirmed: 0, cw_worked: 0, cw_confirmed: 0, dig_worked: 0, dig_confirmed: 0 });
|
||||||
|
const refreshCounts = useCallback(() => { GetSlotStats().then((c: any) => setStats(c)).catch(() => {}); }, []);
|
||||||
|
useEffect(() => { refreshCounts(); }, [refreshCounts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
|
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
|
||||||
const offDone = EventsOn('qslmgr:done', (d: any) => {
|
const offDone = EventsOn('qslmgr:done', (d: any) => {
|
||||||
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} —`]);
|
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} —`]);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
refreshCounts(); // a download may have added confirmations
|
||||||
});
|
});
|
||||||
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
|
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
|
||||||
setConfirmations((list ?? []) as Confirmation[]);
|
setConfirmations((list ?? []) as Confirmation[]);
|
||||||
setViewMode('confirmations');
|
setViewMode('confirmations');
|
||||||
|
refreshCounts();
|
||||||
});
|
});
|
||||||
return () => { offLog(); offDone(); offConf(); };
|
return () => { offLog(); offDone(); offConf(); };
|
||||||
}, []);
|
}, [refreshCounts]);
|
||||||
|
|
||||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||||
|
|
||||||
@@ -231,9 +240,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
|
|
||||||
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
||||||
switch (confFilter) {
|
switch (confFilter) {
|
||||||
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
|
case 'new': return c.new_dxcc || c.new_band || c.new_mode || c.new_slot;
|
||||||
case 'dxcc': return c.new_dxcc;
|
case 'dxcc': return c.new_dxcc;
|
||||||
case 'band': return c.new_band;
|
case 'band': return c.new_band;
|
||||||
|
case 'mode': return c.new_mode;
|
||||||
case 'slot': return c.new_slot;
|
case 'slot': return c.new_slot;
|
||||||
default: return true;
|
default: return true;
|
||||||
}
|
}
|
||||||
@@ -353,6 +363,22 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
{/* Worked / confirmed tallies. Slots by mode class (PH/CW/DIGI), with the
|
||||||
|
per-class breakdown so the totals are checkable. */}
|
||||||
|
<div className="self-center flex items-center gap-3 text-[11px] font-mono" title={t('qslm.countsTip')}>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">{t('qslm.slots')}</span>
|
||||||
|
<span className="font-semibold">{stats.slots_worked}</span>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span className="text-success font-semibold">{stats.slots_confirmed}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">DXCC</span>
|
||||||
|
<span className="font-semibold">{stats.dxcc_worked}</span>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span className="text-success font-semibold">{stats.dxcc_confirmed}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{service === 'pota' && potaRes && (
|
{service === 'pota' && potaRes && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{t('qslm.potaSummaryShort', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched })}{potaRes.skipped_other_call > 0 ? t('qslm.potaOtherCall', { n: potaRes.skipped_other_call }) : ''} / {potaRes.fetched}
|
{t('qslm.potaSummaryShort', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched })}{potaRes.skipped_other_call > 0 ? t('qslm.potaOtherCall', { n: potaRes.skipped_other_call }) : ''} / {potaRes.fetched}
|
||||||
@@ -371,6 +397,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
|
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
|
||||||
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
|
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
|
||||||
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
|
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
|
||||||
|
<SelectItem value="mode">{t('qslm.filterNewMode')}</SelectItem>
|
||||||
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
|
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -491,6 +518,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<td className="py-1 px-2">
|
<td className="py-1 px-2">
|
||||||
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</span>
|
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</span>
|
||||||
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</span>
|
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</span>
|
||||||
|
: c.new_mode ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-info-muted text-info-muted-foreground border border-info-border">{t('qslm.newMode')}</span>
|
||||||
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-caution-muted text-caution-muted-foreground border border-caution-border">{t('qslm.newSlot')}</span>
|
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-caution-muted text-caution-muted-foreground border border-caution-border">{t('qslm.newSlot')}</span>
|
||||||
: <span className="text-muted-foreground/50">—</span>}
|
: <span className="text-muted-foreground/50">—</span>}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
import { Trash2, Search, Loader2 } from 'lucide-react';
|
||||||
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||||
|
import { rstOptions, type RSTLists } from '@/lib/rst';
|
||||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||||
@@ -80,11 +81,23 @@ function StatusCell({ value }: { value?: string }) {
|
|||||||
if (v === '') {
|
if (v === '') {
|
||||||
return <span className="block text-center text-[11px] text-muted-foreground">—</span>;
|
return <span className="block text-center text-[11px] text-muted-foreground">—</span>;
|
||||||
}
|
}
|
||||||
|
// One colour per state, and each colour means something:
|
||||||
|
// Yes green — confirmed, the thing you wanted
|
||||||
|
// Requested blue — in flight, waiting on the other end. Not a problem.
|
||||||
|
// Modified orange — uploaded, then the QSO changed: it needs re-uploading.
|
||||||
|
// This is the ONLY state asking for action, so it gets the
|
||||||
|
// only alarming colour.
|
||||||
|
// No neutral — nothing done yet. Every freshly logged QSO is "No" on
|
||||||
|
// every row; painting that orange (as it used to be, in the
|
||||||
|
// same orange as Requested) made the table shout about a
|
||||||
|
// non-problem and told you nothing apart.
|
||||||
|
// Ignore dashed — deliberately excluded, on purpose.
|
||||||
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
||||||
const cls = v === 'Y' ? 'bg-success text-success-foreground'
|
const cls = v === 'Y' ? 'bg-success text-success-foreground border border-success'
|
||||||
: v === 'R' ? 'bg-warning text-warning-foreground'
|
: v === 'R' ? 'bg-info-muted text-info-muted-foreground border border-info-border'
|
||||||
: v === 'I' ? 'bg-muted-foreground text-background'
|
: v === 'M' ? 'bg-warning text-warning-foreground border border-warning'
|
||||||
: 'bg-warning text-warning-foreground';
|
: v === 'I' ? 'bg-muted text-muted-foreground border border-dashed border-border italic'
|
||||||
|
: 'bg-muted text-muted-foreground border border-border';
|
||||||
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
|
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +176,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
return base;
|
return base;
|
||||||
}, [modes, qso.mode]);
|
}, [modes, qso.mode]);
|
||||||
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
||||||
|
// Per-mode RST dropdown choices, loaded from the same settings as the entry form.
|
||||||
|
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
|
||||||
|
useEffect(() => {
|
||||||
|
GetListsSettings()
|
||||||
|
.then((l: any) => setRstLists({ phone: l?.rst_phone ?? [], cw: l?.rst_cw ?? [], digital: l?.rst_digital ?? [] }))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
|
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
|
||||||
const splitHz = (hz?: number) => hz
|
const splitHz = (hz?: number) => hz
|
||||||
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
||||||
@@ -392,9 +412,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-20"><Label>S</Label>
|
<div className="flex flex-col w-20"><Label>S</Label>
|
||||||
<Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} className="font-mono" /></div>
|
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_sent', v)} /></div>
|
||||||
<div className="flex flex-col w-20"><Label>R</Label>
|
<div className="flex flex-col w-20"><Label>R</Label>
|
||||||
<Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} className="font-mono" /></div>
|
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
|
||||||
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||||
title={t('qedit.fetchTitle')}>
|
title={t('qedit.fetchTitle')}>
|
||||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||||
@@ -571,9 +591,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: live status grid for every channel */}
|
{/* Right: live status grid for every channel.
|
||||||
<div className="w-72 shrink-0">
|
Sized by its content, not pinned to a width: a fixed 288px box
|
||||||
<table className="w-full border-separate" style={{ borderSpacing: 4 }}>
|
left the label column too narrow, so "QSL (paper)" wrapped onto
|
||||||
|
two lines and padded out the whole row. There is spare width to
|
||||||
|
the right — spend it on the label. */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<table className="border-separate" style={{ borderSpacing: 4 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
<th className="text-left font-semibold">{t('qedit.thType')}</th>
|
<th className="text-left font-semibold">{t('qedit.thType')}</th>
|
||||||
@@ -584,7 +608,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<tbody>
|
<tbody>
|
||||||
{CONFIRMATIONS.map((c) => (
|
{CONFIRMATIONS.map((c) => (
|
||||||
<tr key={c.key} className="text-xs">
|
<tr key={c.key} className="text-xs">
|
||||||
<td className="font-medium pr-2 py-0.5">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
|
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
|
||||||
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
||||||
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ type Props = {
|
|||||||
// Bump this number to programmatically select every row (e.g. after a search
|
// Bump this number to programmatically select every row (e.g. after a search
|
||||||
// in the QSL Manager, where the default is "all selected").
|
// in the QSL Manager, where the default is "all selected").
|
||||||
selectAllSignal?: number;
|
selectAllSignal?: number;
|
||||||
|
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
||||||
|
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
||||||
|
// reuse this grid elsewhere with its OWN independent column config.
|
||||||
|
storageKey?: string;
|
||||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||||
|
onRowClicked?: (q: QSOForm) => void;
|
||||||
onRowSelected?: (ids: number[]) => void;
|
onRowSelected?: (ids: number[]) => void;
|
||||||
onUpdateFromCty?: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
@@ -44,12 +49,16 @@ type Props = {
|
|||||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onExportCabrilloFiltered?: () => void;
|
onExportCabrilloFiltered?: () => void;
|
||||||
onDelete?: (ids: number[]) => void;
|
onDelete?: (ids: number[]) => void;
|
||||||
|
// Reports how many rows the grid shows after its COLUMN filters (the funnel
|
||||||
|
// icons), or null when no column filter is active — so the parent's "Showing X
|
||||||
|
// of Y" can reflect them. Fired on filter change and when the data updates.
|
||||||
|
onFilteredCountChange?: (count: number | null) => void;
|
||||||
// One column per defined award; the cell shows the reference this QSO counts
|
// One column per defined award; the cell shows the reference this QSO counts
|
||||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||||
awardCols?: { code: string; name: string }[];
|
awardCols?: { code: string; name: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
|
||||||
|
|
||||||
function fmtMhzDots(hz?: number): string {
|
function fmtMhzDots(hz?: number): string {
|
||||||
if (!hz) return '';
|
if (!hz) return '';
|
||||||
@@ -203,6 +212,13 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
{ group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 },
|
{ group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 },
|
||||||
{ group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 },
|
{ group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 },
|
||||||
{ group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 },
|
{ group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_name'), colId: 'my_name', headerName: t('rqg.c.my_name'), field: 'my_name' as any, width: 120 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_wwff'), colId: 'my_wwff_ref', headerName: t('rqg.c.my_wwff'), field: 'my_wwff_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_sig'), colId: 'my_sig', headerName: t('rqg.c.my_sig'), field: 'my_sig' as any, width: 90 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_sig_info'), colId: 'my_sig_info', headerName: t('rqg.c.my_sig_info'), field: 'my_sig_info' as any, width: 120 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_arrl_sect'), colId: 'my_arrl_sect', headerName: t('rqg.c.my_arrl_sect'), field: 'my_arrl_sect' as any, width: 90, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_darc_dok'), colId: 'my_darc_dok', headerName: t('rqg.c.my_darc_dok'), field: 'my_darc_dok' as any, width: 90, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_vucc_grids'),colId: 'my_vucc_grids', headerName: t('rqg.c.my_vucc_grids'), field: 'my_vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
||||||
|
|
||||||
// ── Misc ──
|
// ── Misc ──
|
||||||
{ group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
{ group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
||||||
@@ -226,10 +242,20 @@ const GRP_KEYS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
|
// Award columns are governed SOLELY by the awardShown code-set, never by AG
|
||||||
|
// Grid's saved column state. Stripping them here (on both save and restore)
|
||||||
|
// stops a stale saved state from re-hiding a shown award column on every
|
||||||
|
// awardCols rebuild — the desync that made award columns vanish mid-session.
|
||||||
|
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||||
|
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||||
|
|
||||||
|
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
// Column-layout persistence keys — scoped by storageKey so a reused instance
|
||||||
|
// (e.g. the Net panel) keeps its own layout independent of the main log.
|
||||||
|
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
|
|
||||||
// Localized column catalog — rebuilt when the language changes.
|
// Localized column catalog — rebuilt when the language changes.
|
||||||
@@ -262,6 +288,30 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
// auto-save during that window. Set in the memo (runs at render, before the
|
// auto-save during that window. Set in the memo (runs at render, before the
|
||||||
// column events) and cleared by the re-apply effect below.
|
// column events) and cleared by the re-apply effect below.
|
||||||
const restoringRef = useRef(true);
|
const restoringRef = useRef(true);
|
||||||
|
|
||||||
|
// Award-column visibility is stored as an explicit set of award CODES, NOT via
|
||||||
|
// AG Grid's column-state round-trip. Those columns load asynchronously and
|
||||||
|
// race the state restore, and AG Grid preserves an existing column's visibility
|
||||||
|
// across a columnDefs rebuild instead of re-reading `hide` — which is exactly
|
||||||
|
// why previously-shown award columns kept vanishing on reopen. A dedicated set
|
||||||
|
// is deterministic: it drives `hide` directly and is re-enforced below.
|
||||||
|
const AWARD_SHOWN_KEY = storageKey ? `hamlog.awardColsShown.${storageKey}` : 'hamlog.awardColsShown';
|
||||||
|
const [awardShown, setAwardShown] = useState<Set<string>>(() => new Set((loadLocal(AWARD_SHOWN_KEY) ?? []).map((s) => String(s).toUpperCase())));
|
||||||
|
useEffect(() => {
|
||||||
|
// Fresh machine: hydrate the set from the portable DB copy, then seed the cache.
|
||||||
|
if (loadLocal(AWARD_SHOWN_KEY)) return;
|
||||||
|
loadRemote(AWARD_SHOWN_KEY).then((remote) => {
|
||||||
|
if (remote && remote.length) {
|
||||||
|
seedLocal(AWARD_SHOWN_KEY, remote);
|
||||||
|
setAwardShown(new Set(remote.map((s: any) => String(s).toUpperCase())));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const persistAwardShown = useCallback((next: Set<string>) => {
|
||||||
|
setAwardShown(next);
|
||||||
|
saveState(AWARD_SHOWN_KEY, [...next]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||||
restoringRef.current = true;
|
restoringRef.current = true;
|
||||||
const base = COL_CATALOG.map((c) => {
|
const base = COL_CATALOG.map((c) => {
|
||||||
@@ -274,16 +324,26 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
||||||
width: 110,
|
width: 110,
|
||||||
cellClass: 'text-[11px]',
|
cellClass: 'text-[11px]',
|
||||||
// Hidden by DEFAULT (award columns are opt-in). Without this, AG Grid shows
|
// Visibility comes from the persisted award-code set, so a column the user
|
||||||
// them whenever the saved column state doesn't cover them — a newly-added
|
// showed reappears on reopen and one they didn't stays hidden.
|
||||||
// award, an empty cache, or a rebuild that runs before the saved state is
|
hide: !awardShown.has(a.code.toUpperCase()),
|
||||||
// re-applied — which is why "all award columns" kept reappearing. The user's
|
|
||||||
// saved state (a column they explicitly showed) still overrides this.
|
|
||||||
hide: true,
|
|
||||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
}));
|
}));
|
||||||
return [...base, ...awards];
|
return [...base, ...awards];
|
||||||
}, [awardCols, COL_CATALOG, t]);
|
}, [awardCols, COL_CATALOG, t, awardShown]);
|
||||||
|
|
||||||
|
// Enforce award-column visibility via the API after the columns (re)appear —
|
||||||
|
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
||||||
|
// so we set it explicitly whenever the award set or the loaded columns change.
|
||||||
|
useEffect(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api || !awardCols?.length) return;
|
||||||
|
for (const a of awardCols) {
|
||||||
|
const want = awardShown.has(a.code.toUpperCase());
|
||||||
|
const col = api.getColumn(`award_${a.code}`);
|
||||||
|
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
|
||||||
|
}
|
||||||
|
}, [awardCols, awardShown]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true,
|
sortable: true,
|
||||||
@@ -293,36 +353,45 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
function onGridReady(e: GridReadyEvent) {
|
||||||
const local = loadLocal(COL_STATE_KEY);
|
const local = loadLocal(colStateKey);
|
||||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
||||||
// Fall back to the portable DB copy when the local cache is empty
|
// Fall back to the portable DB copy when the local cache is empty
|
||||||
// (fresh machine / after a reinstall), then re-seed the cache.
|
// (fresh machine / after a reinstall), then re-seed the cache.
|
||||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
loadRemote(colStateKey).then((remote) => {
|
||||||
if (remote && !local) {
|
if (remote && !local) {
|
||||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true });
|
||||||
seedLocal(COL_STATE_KEY, remote);
|
seedLocal(colStateKey, remote);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Report the post-column-filter row count (funnel filters) to the parent, or
|
||||||
|
// null when no column filter is active, so "Showing X of Y" reflects them.
|
||||||
|
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
||||||
|
const api = e?.api ?? gridRef.current?.api;
|
||||||
|
if (!api || !onFilteredCountChange) return;
|
||||||
|
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
||||||
|
}, [onFilteredCountChange]);
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
if (state) saveState(COL_STATE_KEY, state);
|
if (state) saveState(colStateKey, stripAwardCols(state));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// The award columns load asynchronously; when they arrive (or change) the
|
// columnDefs is rebuilt whenever the award columns load OR the user toggles an
|
||||||
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide`
|
// award column (both change the memo → restoringRef flips true at line 316). Each
|
||||||
// default — wiping the user's saved visibility (award columns reappear,
|
// rebuild makes AG Grid re-apply every colDef's `hide` default, wiping the user's
|
||||||
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after
|
// saved visibility of the NON-award columns (QTH/Grid reappear, a manually-shown
|
||||||
// every rebuild so the user's choices win. No-op before the grid is ready.
|
// LoTW-sent vanishes). Re-apply the saved (award-stripped) state after EVERY such
|
||||||
|
// rebuild — hence awardShown in the deps, not just awardCols; without it, toggling
|
||||||
|
// an award reset the other columns AND left restoringRef stuck true (saving off).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
const local = loadLocal(COL_STATE_KEY);
|
const local = loadLocal(colStateKey);
|
||||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
if (api && local) api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
||||||
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [awardCols]);
|
}, [awardCols, awardShown]);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
@@ -342,10 +411,22 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
// state for "which columns are visible" — AG Grid's column state is the
|
// state for "which columns are visible" — AG Grid's column state is the
|
||||||
// source of truth, and saveColumnState persists it.
|
// source of truth, and saveColumnState persists it.
|
||||||
function isColVisible(colId: string): boolean {
|
function isColVisible(colId: string): boolean {
|
||||||
|
// Award columns: the persisted set is the source of truth (see awardShown).
|
||||||
|
if (colId.startsWith('award_')) return awardShown.has(colId.slice('award_'.length).toUpperCase());
|
||||||
const col = gridRef.current?.api?.getColumn(colId);
|
const col = gridRef.current?.api?.getColumn(colId);
|
||||||
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
||||||
}
|
}
|
||||||
function setColVisible(colId: string, visible: boolean) {
|
function setColVisible(colId: string, visible: boolean) {
|
||||||
|
// Award columns are driven by the persisted code set — update it and let the
|
||||||
|
// enforce effect apply visibility. This is what makes them stick across reopen.
|
||||||
|
if (colId.startsWith('award_')) {
|
||||||
|
const code = colId.slice('award_'.length).toUpperCase();
|
||||||
|
const next = new Set(awardShown);
|
||||||
|
if (visible) next.add(code); else next.delete(code);
|
||||||
|
persistAwardShown(next);
|
||||||
|
gridRef.current?.api?.setColumnsVisible([colId], visible);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
api.setColumnsVisible([colId], visible);
|
api.setColumnsVisible([colId], visible);
|
||||||
@@ -373,6 +454,9 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
api.setColumnsVisible(visible, true);
|
api.setColumnsVisible(visible, true);
|
||||||
api.setColumnsVisible(hidden, false);
|
api.setColumnsVisible(hidden, false);
|
||||||
saveColumnState();
|
saveColumnState();
|
||||||
|
// Award columns are opt-in: reset hides them all.
|
||||||
|
persistAwardShown(new Set());
|
||||||
|
(awardCols ?? []).forEach((a) => api.setColumnsVisible([`award_${a.code}`], false));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -396,12 +480,15 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
onGridReady={onGridReady}
|
onGridReady={onGridReady}
|
||||||
|
onFilterChanged={reportFilteredCount}
|
||||||
|
onModelUpdated={reportFilteredCount}
|
||||||
onColumnResized={saveColumnState}
|
onColumnResized={saveColumnState}
|
||||||
onColumnMoved={saveColumnState}
|
onColumnMoved={saveColumnState}
|
||||||
onColumnPinned={saveColumnState}
|
onColumnPinned={saveColumnState}
|
||||||
onColumnVisible={saveColumnState}
|
onColumnVisible={saveColumnState}
|
||||||
onSortChanged={saveColumnState}
|
onSortChanged={saveColumnState}
|
||||||
onRowDoubleClicked={handleRowDoubleClicked}
|
onRowDoubleClicked={handleRowDoubleClicked}
|
||||||
|
onRowClicked={(e) => e.data && onRowClicked?.(e.data)}
|
||||||
onSelectionChanged={onSelectionChanged}
|
onSelectionChanged={onSelectionChanged}
|
||||||
onCellContextMenu={onCellContextMenu}
|
onCellContextMenu={onCellContextMenu}
|
||||||
preventDefaultOnContextMenu
|
preventDefaultOnContextMenu
|
||||||
@@ -469,8 +556,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span>
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>{t('rqg.all')}</button>
|
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { persistAwardShown(new Set(awardCols.map((a) => a.code.toUpperCase()))); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], true)); }}>{t('rqg.all')}</button>
|
||||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('rqg.none')}</button>
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { persistAwardShown(new Set()); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], false)); }}>{t('rqg.none')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||||
ChevronDown, ChevronRight,
|
ChevronDown, ChevronRight,
|
||||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check,
|
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff, Pencil,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
GetPGXLSettings, SavePGXLSettings,
|
GetPGXLSettings, SavePGXLSettings, GetSPEStatus, SPESetOperate,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
ConnectClusterServer, DisconnectClusterServer,
|
ConnectClusterServer, DisconnectClusterServer,
|
||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase,
|
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase,
|
||||||
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
||||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||||
@@ -38,9 +38,12 @@ import {
|
|||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
DownloadLoTWUsers, GetLoTWUsersStatus,
|
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||||
|
DownloadULSCounties, ULSStatus, BackfillUSCounties,
|
||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
GetUIPref, SetUIPref,
|
GetUIPref, SetUIPref,
|
||||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
||||||
|
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||||
|
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -168,6 +171,7 @@ type SectionId =
|
|||||||
| 'confirmations'
|
| 'confirmations'
|
||||||
| 'external-services'
|
| 'external-services'
|
||||||
| 'udp'
|
| 'udp'
|
||||||
|
| 'adifmon'
|
||||||
| 'lookup'
|
| 'lookup'
|
||||||
| 'lists-bands'
|
| 'lists-bands'
|
||||||
| 'lists-modes'
|
| 'lists-modes'
|
||||||
@@ -175,6 +179,7 @@ type SectionId =
|
|||||||
| 'backup'
|
| 'backup'
|
||||||
| 'database'
|
| 'database'
|
||||||
| 'autostart'
|
| 'autostart'
|
||||||
|
| 'uscounties'
|
||||||
| 'awards'
|
| 'awards'
|
||||||
| 'cat'
|
| 'cat'
|
||||||
| 'rotator'
|
| 'rotator'
|
||||||
@@ -183,6 +188,7 @@ type SectionId =
|
|||||||
| 'antgenius'
|
| 'antgenius'
|
||||||
| 'pgxl'
|
| 'pgxl'
|
||||||
| 'flex'
|
| 'flex'
|
||||||
|
| 'relayauto'
|
||||||
| 'audio';
|
| 'audio';
|
||||||
|
|
||||||
type TreeNode =
|
type TreeNode =
|
||||||
@@ -200,6 +206,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius' },
|
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius' },
|
||||||
{ 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.audio'), id: 'audio' },
|
{ kind: 'item', label: t('sec.audio'), id: 'audio' },
|
||||||
];
|
];
|
||||||
return [
|
return [
|
||||||
@@ -223,6 +230,8 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
]},
|
]},
|
||||||
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
||||||
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
||||||
|
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
|
||||||
|
{ kind: 'item', label: t('sec.uscounties'), id: 'uscounties' },
|
||||||
{ kind: 'item', label: t('sec.database'), id: 'database' },
|
{ kind: 'item', label: t('sec.database'), id: 'database' },
|
||||||
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' },
|
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' },
|
||||||
],
|
],
|
||||||
@@ -238,8 +247,11 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
|||||||
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
|
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
|
||||||
'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
||||||
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
||||||
|
adifmon: 'sec.adifmon',
|
||||||
|
uscounties: 'sec.uscounties',
|
||||||
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
|
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
|
||||||
antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
|
antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
|
||||||
|
relayauto: 'sec.relayauto',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map section id → friendly name (used in breadcrumb / placeholders).
|
// Map section id → friendly name (used in breadcrumb / placeholders).
|
||||||
@@ -257,14 +269,16 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
database: 'Database',
|
database: 'Database',
|
||||||
autostart: 'Autostart',
|
autostart: 'Autostart',
|
||||||
udp: 'UDP integrations',
|
udp: 'UDP integrations',
|
||||||
|
adifmon: 'ADIF monitor',
|
||||||
awards: 'Awards',
|
awards: 'Awards',
|
||||||
cat: 'CAT interface',
|
cat: 'CAT interface',
|
||||||
rotator: 'PstRotator',
|
rotator: 'Rotator',
|
||||||
winkeyer: 'CW Keyer',
|
winkeyer: 'CW Keyer',
|
||||||
antenna: 'UltraBeam',
|
antenna: 'Ultrabeam / Steppir',
|
||||||
antgenius: 'Antenna Genius',
|
antgenius: 'Antenna Genius',
|
||||||
pgxl: 'Power Genius',
|
pgxl: 'Amplifier',
|
||||||
flex: 'FlexRadio',
|
flex: 'FlexRadio',
|
||||||
|
relayauto: 'Relay auto-control',
|
||||||
audio: 'Audio devices',
|
audio: 'Audio devices',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -536,6 +550,7 @@ function AutostartPanelComponent() {
|
|||||||
// (a random install ID + version + OS, sent once a day). Real component so it
|
// (a random install ID + version + OS, sent once a day). Real component so it
|
||||||
// can own its state; embedded inside GeneralPanel.
|
// can own its state; embedded inside GeneralPanel.
|
||||||
function TelemetryToggle() {
|
function TelemetryToggle() {
|
||||||
|
const { t } = useI18n();
|
||||||
const [on, setOn] = useState(true);
|
const [on, setOn] = useState(true);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -545,8 +560,8 @@ function TelemetryToggle() {
|
|||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={on} disabled={!loaded}
|
<Checkbox checked={on} disabled={!loaded}
|
||||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
|
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
|
||||||
Send anonymous usage statistics
|
{t('settings.telemetry')}
|
||||||
<span className="text-xs text-muted-foreground">(install ID + version + OS, once a day — no callsign or QSO data)</span>
|
<span className="text-xs text-muted-foreground">({t('settings.telemetryHint')})</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -556,6 +571,7 @@ function TelemetryToggle() {
|
|||||||
// events — a small web script on your server renders it for the QRZ page. Only
|
// events — a small web script on your server renders it for the QRZ page. Only
|
||||||
// useful on a MySQL logbook. Self-contained component (owns its async state).
|
// useful on a MySQL logbook. Self-contained component (owns its async state).
|
||||||
function LiveStatusToggle() {
|
function LiveStatusToggle() {
|
||||||
|
const { t } = useI18n();
|
||||||
const [on, setOn] = useState(false);
|
const [on, setOn] = useState(false);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -565,34 +581,238 @@ function LiveStatusToggle() {
|
|||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={on} disabled={!loaded}
|
<Checkbox checked={on} disabled={!loaded}
|
||||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetLiveStatusEnabled(v).catch(() => {}); }} />
|
onCheckedChange={(c) => { const v = !!c; setOn(v); SetLiveStatusEnabled(v).catch(() => {}); }} />
|
||||||
Publish live operator status <span className="text-xs text-muted-foreground">(multi-op on shared MySQL — feeds a QRZ live page)</span>
|
{t('settings.liveStatus')} <span className="text-xs text-muted-foreground">({t('settings.liveStatusHint')})</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ADIFMonitorPanel watches a list of external ADIF files (fldigi RTTY, N1MM,
|
||||||
|
// VarAC…) and auto-imports newly appended QSOs — deliberately option-free: a
|
||||||
|
// contact that arrives is imported and uploaded automatically like any log entry.
|
||||||
|
type ADIFWatchFileUI = { path: string; enabled: boolean; offset: number };
|
||||||
|
type ADIFMonitorCfgUI = { enabled: boolean; files: ADIFWatchFileUI[] };
|
||||||
|
function ADIFMonitorPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [cfg, setCfg] = useState<ADIFMonitorCfgUI>({ enabled: false, files: [] });
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
GetADIFMonitor()
|
||||||
|
.then((c: any) => { if (c) setCfg({ enabled: !!c.enabled, files: (c.files ?? []) as ADIFWatchFileUI[] }); })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoaded(true));
|
||||||
|
}, []);
|
||||||
|
// Offsets are managed backend-side; SaveADIFMonitor ignores the ones we send and
|
||||||
|
// keeps each existing file's read position (a new file starts at end-of-file).
|
||||||
|
const persist = (next: ADIFMonitorCfgUI) => { setCfg(next); SaveADIFMonitor(next as any).catch(() => {}); };
|
||||||
|
const addFile = async () => {
|
||||||
|
try {
|
||||||
|
const p = await PickADIFMonitorFile();
|
||||||
|
if (!p || cfg.files.some((f) => f.path === p)) return;
|
||||||
|
persist({ ...cfg, files: [...cfg.files, { path: p, enabled: true, offset: -1 }] });
|
||||||
|
} catch { /* dialog cancelled */ }
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<p className="text-xs text-muted-foreground">{t('adifmon.hint')}</p>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={cfg.enabled} disabled={!loaded} onCheckedChange={(c) => persist({ ...cfg, enabled: !!c })} />
|
||||||
|
{t('adifmon.enable')}
|
||||||
|
</label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{cfg.files.length === 0 && <p className="text-xs text-muted-foreground italic">{t('adifmon.empty')}</p>}
|
||||||
|
{cfg.files.map((f, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-2 py-1.5">
|
||||||
|
<Checkbox checked={f.enabled}
|
||||||
|
onCheckedChange={(c) => persist({ ...cfg, files: cfg.files.map((x, idx) => idx === i ? { ...x, enabled: !!c } : x) })} />
|
||||||
|
<span className="flex-1 font-mono text-xs truncate" title={f.path}>{f.path}</span>
|
||||||
|
<button type="button" title={t('adifmon.remove')}
|
||||||
|
className="text-muted-foreground hover:text-destructive shrink-0"
|
||||||
|
onClick={() => persist({ ...cfg, files: cfg.files.filter((_, idx) => idx !== i) })}>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={addFile}>
|
||||||
|
<FolderOpen className="size-3.5 mr-1" /> {t('adifmon.add')}
|
||||||
|
</Button>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('adifmon.note')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelayAutoPanel configures automatic control of the Station Control relay boards
|
||||||
|
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
|
||||||
|
// off, a frequency window (kHz), or a set of bands.
|
||||||
|
type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz: number; freq_hi_khz: number; bands: string[] };
|
||||||
|
type StationDevUI = { id: string; type: string; name: string; labels: string[] };
|
||||||
|
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
|
||||||
|
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
|
||||||
|
|
||||||
|
// Live SPE Expert amplifier status + OPERATE/STANDBY toggle. Module-scoped (not a
|
||||||
|
// nested component) so it isn't remounted on every parent render. Polls once a
|
||||||
|
// second while shown.
|
||||||
|
function SPEStatusCard() {
|
||||||
|
const [st, setSt] = useState<any>({ connected: false });
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = () => GetSPEStatus().then((s) => alive && setSt(s || {})).catch(() => {});
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
const operate = !!st.operate;
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} />
|
||||||
|
<span className="font-semibold">{st.connected ? `SPE ${st.model || 'Expert'}` : 'SPE — not connected'}</span>
|
||||||
|
{!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>}
|
||||||
|
<span className="flex-1" />
|
||||||
|
<Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected}
|
||||||
|
onClick={() => SPESetOperate(!operate).catch(() => {})}
|
||||||
|
title="Toggle OPERATE / STANDBY">
|
||||||
|
{operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{st.connected && (
|
||||||
|
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
|
||||||
|
<div>{st.tx ? 'TX' : 'RX'}</div>
|
||||||
|
<div>Band {st.band}</div>
|
||||||
|
<div>Pwr {st.power_level}</div>
|
||||||
|
<div>{st.output_w} W</div>
|
||||||
|
<div>SWR {Number(st.swr_ant ?? 0).toFixed(2)}</div>
|
||||||
|
<div>{st.temp_c}°C</div>
|
||||||
|
<div>{st.volt_pa} V</div>
|
||||||
|
<div>{st.curr_pa} A</div>
|
||||||
|
{(st.warnings || st.alarms) && <div className="col-span-4 text-warning">⚠ {st.warnings} {st.alarms}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function RelayAutoPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [rules, setRules] = useState<RelayRuleUI[]>([]);
|
||||||
|
const [devices, setDevices] = useState<StationDevUI[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([GetRelayAuto(), GetStationDevices()])
|
||||||
|
.then(([cfg, devs]: any[]) => {
|
||||||
|
setEnabled(!!cfg?.enabled);
|
||||||
|
setRules((cfg?.rules ?? []) as RelayRuleUI[]);
|
||||||
|
setDevices((devs ?? []) as StationDevUI[]);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoaded(true));
|
||||||
|
}, []);
|
||||||
|
const save = (en: boolean, rs: RelayRuleUI[]) => { SaveRelayAuto({ enabled: en, rules: rs } as any).catch(() => {}); };
|
||||||
|
const ruleFor = (dev: string, relay: number): RelayRuleUI =>
|
||||||
|
rules.find((r) => r.device_id === dev && r.relay === relay) ?? { device_id: dev, relay, mode: 'off', freq_lo_khz: 0, freq_hi_khz: 0, bands: [] };
|
||||||
|
// Apply a patch and persist (commit=true) or keep local only (commit=false, for
|
||||||
|
// freq inputs that persist on blur so we don't switch relays on every keystroke).
|
||||||
|
const patchRule = (dev: string, relay: number, patch: Partial<RelayRuleUI>, commit = true) => {
|
||||||
|
const next = { ...ruleFor(dev, relay), ...patch };
|
||||||
|
const others = rules.filter((r) => !(r.device_id === dev && r.relay === relay));
|
||||||
|
const all = [...others, next];
|
||||||
|
setRules(all);
|
||||||
|
if (commit) save(enabled, all);
|
||||||
|
};
|
||||||
|
const toggleBand = (dev: string, relay: number, band: string) => {
|
||||||
|
const cur = ruleFor(dev, relay);
|
||||||
|
const has = cur.bands.includes(band);
|
||||||
|
patchRule(dev, relay, { bands: has ? cur.bands.filter((b) => b !== band) : [...cur.bands, band] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-3xl">
|
||||||
|
<p className="text-xs text-muted-foreground">{t('relayauto.hint')}</p>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={enabled} disabled={!loaded} onCheckedChange={(c) => { const v = !!c; setEnabled(v); save(v, rules); }} />
|
||||||
|
{t('relayauto.enable')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{devices.length === 0 && loaded && (
|
||||||
|
<p className="text-xs text-muted-foreground italic">{t('relayauto.noDevices')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{devices.map((dev) => (
|
||||||
|
<div key={dev.id} className="rounded-md border border-border">
|
||||||
|
<div className="px-3 py-1.5 border-b border-border bg-muted/40 text-xs font-semibold">{dev.name || dev.id}</div>
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
|
{Array.from({ length: (dev.labels?.length || relayCountUI(dev.type)) }, (_, i) => i + 1).map((relay) => {
|
||||||
|
const r = ruleFor(dev.id, relay);
|
||||||
|
const label = (dev.labels?.[relay - 1] || '').trim() || `${t('relayauto.relay')} ${relay}`;
|
||||||
|
return (
|
||||||
|
<div key={relay} className="flex items-start gap-3 px-3 py-2">
|
||||||
|
<span className="w-28 shrink-0 text-xs font-mono pt-1.5 truncate" title={label}>{label}</span>
|
||||||
|
<Select value={r.mode || 'off'} onValueChange={(v) => patchRule(dev.id, relay, { mode: v })}>
|
||||||
|
<SelectTrigger className="h-8 w-32 text-xs shrink-0"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="off">{t('relayauto.modeOff')}</SelectItem>
|
||||||
|
<SelectItem value="freq">{t('relayauto.modeFreq')}</SelectItem>
|
||||||
|
<SelectItem value="band">{t('relayauto.modeBand')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="flex-1 min-w-0 pt-0.5">
|
||||||
|
{r.mode === 'freq' && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<Input type="number" className="h-8 w-24 text-xs" placeholder={t('relayauto.from')}
|
||||||
|
defaultValue={r.freq_lo_khz || ''}
|
||||||
|
onChange={(e) => patchRule(dev.id, relay, { freq_lo_khz: parseFloat(e.target.value) || 0 }, false)}
|
||||||
|
onBlur={() => save(enabled, rules)} />
|
||||||
|
<span className="text-muted-foreground">–</span>
|
||||||
|
<Input type="number" className="h-8 w-24 text-xs" placeholder={t('relayauto.to')}
|
||||||
|
defaultValue={r.freq_hi_khz || ''}
|
||||||
|
onChange={(e) => patchRule(dev.id, relay, { freq_hi_khz: parseFloat(e.target.value) || 0 }, false)}
|
||||||
|
onBlur={() => save(enabled, rules)} />
|
||||||
|
<span className="text-muted-foreground">kHz</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.mode === 'band' && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{RELAY_BANDS.map((b) => {
|
||||||
|
const on = r.bands.includes(b);
|
||||||
|
return (
|
||||||
|
<button key={b} type="button" onClick={() => toggleBand(dev.id, relay, b)}
|
||||||
|
className={cn('px-1.5 py-0.5 rounded text-[11px] font-mono border transition-colors',
|
||||||
|
on ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{b}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// MainViewPanes lets the operator choose what the Main tab's left and right
|
// MainViewPanes lets the operator choose what the Main tab's left and right
|
||||||
// panes show, independently: the great-circle map, the locator street map, the
|
// panes show, independently: the great-circle map, the locator street map, the
|
||||||
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
||||||
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
|
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
|
||||||
const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
|
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol'];
|
||||||
{ value: 'map1', label: 'Map — great-circle + beam' },
|
|
||||||
{ value: 'map2', label: 'Map — locator (street)' },
|
|
||||||
{ value: 'cluster', label: 'Cluster spots' },
|
|
||||||
{ value: 'worked', label: 'Worked before' },
|
|
||||||
{ value: 'recent', label: 'Recent QSOs' },
|
|
||||||
{ value: 'netcontrol', label: 'Net control' },
|
|
||||||
];
|
|
||||||
function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) {
|
function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [left, setLeft] = useState('map1');
|
const [left, setLeft] = useState('map1');
|
||||||
const [right, setRight] = useState('map2');
|
const [right, setRight] = useState('map2');
|
||||||
// Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
|
// Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
|
||||||
const options = [
|
const options = [
|
||||||
...MAIN_PANE_OPTIONS,
|
...MAIN_PANE_VALUES,
|
||||||
...(flexAvailable ? [{ value: 'flex', label: 'FlexRadio controls' }] : []),
|
...(flexAvailable ? ['flex'] : []),
|
||||||
...(icomAvailable ? [{ value: 'icom', label: 'Icom console' }] : []),
|
...(icomAvailable ? ['icom'] : []),
|
||||||
].sort((a, b) => a.label.localeCompare(b.label));
|
].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
|
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_VALUES.includes(v);
|
||||||
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
||||||
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -605,11 +825,11 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||||
<h4 className="text-sm font-semibold text-foreground">Main view</h4>
|
<h4 className="text-sm font-semibold text-foreground">{t('settings.mainView')}</h4>
|
||||||
<p className="text-xs text-muted-foreground">Choose what the Main tab shows on each side (per profile).</p>
|
<p className="text-xs text-muted-foreground">{t('settings.mainViewHint')}</p>
|
||||||
<div className="grid grid-cols-2 gap-3 max-w-xl">
|
<div className="grid grid-cols-2 gap-3 max-w-xl">
|
||||||
<label className="flex flex-col gap-1 text-xs">
|
<label className="flex flex-col gap-1 text-xs">
|
||||||
<span className="text-muted-foreground">Left pane</span>
|
<span className="text-muted-foreground">{t('settings.leftPane')}</span>
|
||||||
<Select value={left} onValueChange={(v) => pick('left', v)}>
|
<Select value={left} onValueChange={(v) => pick('left', v)}>
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -618,7 +838,7 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1 text-xs">
|
<label className="flex flex-col gap-1 text-xs">
|
||||||
<span className="text-muted-foreground">Right pane</span>
|
<span className="text-muted-foreground">{t('settings.rightPane')}</span>
|
||||||
<Select value={right} onValueChange={(v) => pick('right', v)}>
|
<Select value={right} onValueChange={(v) => pick('right', v)}>
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -821,14 +1041,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
digital_default: 'FT8',
|
digital_default: 'FT8',
|
||||||
});
|
});
|
||||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
const [rotator, setRotator] = useState<RotatorSettings>({
|
||||||
enabled: false, host: '127.0.0.1', port: 12000, has_elevation: false,
|
enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1,
|
||||||
});
|
} as any);
|
||||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
// Ultrabeam antenna (TCP) settings.
|
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
|
||||||
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; host: string; port: number; follow: boolean; step_khz: number }>({
|
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; tx_inhibit: boolean }>({
|
||||||
enabled: false, host: '', port: 23, follow: false, step_khz: 50,
|
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, tx_inhibit: false,
|
||||||
});
|
});
|
||||||
const [ubTesting, setUbTesting] = useState(false);
|
const [ubTesting, setUbTesting] = useState(false);
|
||||||
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
@@ -836,8 +1056,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
|
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
|
||||||
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
||||||
|
|
||||||
// PowerGenius XL (4O3A) amp fan-control settings.
|
// Amplifier control settings (PowerGenius XL over TCP; SPE Expert over serial/IP).
|
||||||
const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 });
|
const [pgxl, setPgxl] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number }>(
|
||||||
|
{ enabled: false, type: 'pgxl', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200 });
|
||||||
|
|
||||||
// WinKeyer CW keyer settings + macro editor.
|
// WinKeyer CW keyer settings + macro editor.
|
||||||
type WKMac = { label: string; text: string };
|
type WKMac = { label: string; text: string };
|
||||||
@@ -908,6 +1129,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||||
|
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||||
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
||||||
// Password-encryption (secret vault) state.
|
// Password-encryption (secret vault) state.
|
||||||
const [secret, setSecret] = useState<{ has_passphrase: boolean; unlocked: boolean }>({ has_passphrase: false, unlocked: false });
|
const [secret, setSecret] = useState<{ has_passphrase: boolean; unlocked: boolean }>({ has_passphrase: false, unlocked: false });
|
||||||
@@ -941,6 +1163,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
||||||
});
|
});
|
||||||
const [emailMsg, setEmailMsg] = useState('');
|
const [emailMsg, setEmailMsg] = useState('');
|
||||||
|
const [showSmtpPass, setShowSmtpPass] = useState(false);
|
||||||
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
||||||
// eQSL card e-mail (subject/body templates + auto-send on log).
|
// eQSL card e-mail (subject/body templates + auto-send on log).
|
||||||
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
|
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
|
||||||
@@ -1010,6 +1233,34 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
|
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
|
||||||
finally { setLotwUsersBusy(false); }
|
finally { setLotwUsersBusy(false); }
|
||||||
};
|
};
|
||||||
|
// US Counties (offline FCC ULS) — download progress arrives via events.
|
||||||
|
const [ulsStatus, setUlsStatus] = useState<{ count: number; updated_at?: string }>({ count: 0 });
|
||||||
|
const [ulsBusy, setUlsBusy] = useState(false);
|
||||||
|
const [ulsProgress, setUlsProgress] = useState<{ stage: string; pct: number } | null>(null);
|
||||||
|
const [ulsMsg, setUlsMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
|
const [backfillBusy, setBackfillBusy] = useState(false);
|
||||||
|
const [backfillMsg, setBackfillMsg] = useState<string | null>(null);
|
||||||
|
useEffect(() => { ULSStatus().then((s) => setUlsStatus(s as any)).catch(() => {}); }, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const off1 = EventsOn('uls:progress', (p: any) => setUlsProgress({ stage: p?.stage ?? '', pct: p?.pct ?? 0 }));
|
||||||
|
const off2 = EventsOn('uls:done', async (r: any) => {
|
||||||
|
setUlsBusy(false); setUlsProgress(null);
|
||||||
|
setUlsMsg(r?.ok ? { ok: true, text: t('uscty.done', { n: r?.count ?? 0 }) } : { ok: false, text: r?.error || 'error' });
|
||||||
|
try { const s = await ULSStatus(); setUlsStatus(s as any); } catch {}
|
||||||
|
});
|
||||||
|
return () => { off1(); off2(); };
|
||||||
|
}, []);
|
||||||
|
const downloadUls = async () => {
|
||||||
|
setUlsBusy(true); setUlsMsg(null); setUlsProgress({ stage: '', pct: 0 });
|
||||||
|
try { await DownloadULSCounties(); }
|
||||||
|
catch (e: any) { setUlsBusy(false); setUlsProgress(null); setUlsMsg({ ok: false, text: String(e?.message ?? e) }); }
|
||||||
|
};
|
||||||
|
const runBackfill = async () => {
|
||||||
|
setBackfillBusy(true); setBackfillMsg(null);
|
||||||
|
try { const r: any = await BackfillUSCounties(); setBackfillMsg(t('uscty.backfillDone', { c: r?.county ?? 0, g: r?.grid ?? 0, s: r?.scanned ?? 0 })); }
|
||||||
|
catch (e: any) { setBackfillMsg(String(e?.message ?? e)); }
|
||||||
|
finally { setBackfillBusy(false); }
|
||||||
|
};
|
||||||
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
||||||
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
@@ -2245,7 +2496,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
setRotatorTest(null);
|
setRotatorTest(null);
|
||||||
try {
|
try {
|
||||||
await TestRotator(rotator as any);
|
await TestRotator(rotator as any);
|
||||||
setRotatorTest({ ok: true, msg: t('cat.rotatorOk') });
|
setRotatorTest({ ok: true, msg: (rotator as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2267,36 +2518,92 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UltrabeamPanel() {
|
function UltrabeamPanel() {
|
||||||
|
const isSteppir = ultrabeam.type === 'steppir';
|
||||||
|
const isSerial = isSteppir && ultrabeam.transport === 'serial';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={t('hw.ultrabeam')}
|
title={t('hw.motorAntenna')}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} />
|
<Checkbox checked={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} />
|
||||||
Enable Ultrabeam control
|
{t('hw.motorEnable')}
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1 col-span-2">
|
|
||||||
<Label>Host / IP</Label>
|
|
||||||
<Input
|
|
||||||
value={ultrabeam.host ?? ''}
|
|
||||||
onChange={(e) => setUltrabeam((s) => ({ ...s, host: e.target.value }))}
|
|
||||||
placeholder="192.168.1.50"
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>TCP port</Label>
|
<Label>{t('hw.motorType')}</Label>
|
||||||
<Input
|
{/* Ultrabeam is TCP only; picking it forces the transport back to TCP
|
||||||
type="number" min={1} max={65535}
|
so the serial fields never apply to it. */}
|
||||||
value={ultrabeam.port}
|
<Select value={ultrabeam.type ?? 'ultrabeam'}
|
||||||
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))}
|
onValueChange={(v) => setUltrabeam((s) => ({ ...s, type: v, transport: v === 'ultrabeam' ? 'tcp' : s.transport }))}>
|
||||||
className="font-mono"
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
/>
|
<SelectContent>
|
||||||
|
<SelectItem value="ultrabeam">Ultrabeam</SelectItem>
|
||||||
|
<SelectItem value="steppir">SteppIR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{isSteppir && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('hw.motorTransport')}</Label>
|
||||||
|
<Select value={ultrabeam.transport ?? 'tcp'}
|
||||||
|
onValueChange={(v) => setUltrabeam((s) => ({ ...s, transport: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="tcp">{t('hw.motorTcp')}</SelectItem>
|
||||||
|
<SelectItem value="serial">{t('hw.motorSerial')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isSerial ? (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>{t('hw.motorCom')}</Label>
|
||||||
|
<Input
|
||||||
|
value={ultrabeam.com ?? ''}
|
||||||
|
onChange={(e) => setUltrabeam((s) => ({ ...s, com: e.target.value }))}
|
||||||
|
placeholder="COM3"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('hw.motorBaud')}</Label>
|
||||||
|
<Select value={String(ultrabeam.baud || 9600)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, baud: parseInt(v, 10) || 9600 }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[1200, 4800, 9600, 19200].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Host / IP</Label>
|
||||||
|
<Input
|
||||||
|
value={ultrabeam.host ?? ''}
|
||||||
|
onChange={(e) => setUltrabeam((s) => ({ ...s, host: e.target.value }))}
|
||||||
|
placeholder="192.168.1.50"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>TCP port</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min={1} max={65535}
|
||||||
|
value={ultrabeam.port}
|
||||||
|
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isSteppir && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('hw.steppirHint')}</p>
|
||||||
|
)}
|
||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
|
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
|
||||||
@@ -2317,8 +2624,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-1">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={ultrabeam.tx_inhibit} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, tx_inhibit: !!c }))} />
|
||||||
|
{t('hw.motorTxInhibit')}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground pl-6">{t('hw.motorTxInhibitHint')}</p>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || !ultrabeam.host.trim()}>
|
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || (isSerial ? !ultrabeam.com.trim() : !ultrabeam.host.trim())}>
|
||||||
{ubTesting ? t('hw.connecting') : t('hw.testConn')}
|
{ubTesting ? t('hw.connecting') : t('hw.testConn')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2375,77 +2689,163 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PGXLPanelSettings() {
|
function PGXLPanelSettings() {
|
||||||
|
const isPGXL = pgxl.type === 'pgxl';
|
||||||
|
const isSerial = pgxl.transport === 'serial';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader title="Amplifier" />
|
||||||
title="Power Genius XL"
|
|
||||||
/>
|
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} />
|
<Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} />
|
||||||
Enable PowerGenius fan control
|
Enable amplifier control
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Label>Host / IP</Label>
|
|
||||||
<Input
|
|
||||||
value={pgxl.host ?? ''}
|
|
||||||
onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
|
|
||||||
placeholder="192.168.1.70"
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>TCP port</Label>
|
<Label>Amplifier</Label>
|
||||||
<Input
|
<Select value={pgxl.type} onValueChange={(v) => setPgxl((s) => ({ ...s, type: v, transport: v === 'pgxl' ? 'tcp' : s.transport }))}>
|
||||||
type="number" min={1} max={65535}
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
value={pgxl.port}
|
<SelectContent>
|
||||||
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))}
|
<SelectItem value="pgxl">4O3A PowerGenius XL</SelectItem>
|
||||||
className="font-mono"
|
<SelectItem value="spe13">SPE Expert 1.3K-FA</SelectItem>
|
||||||
/>
|
<SelectItem value="spe15">SPE Expert 1.5K-FA</SelectItem>
|
||||||
|
<SelectItem value="spe2k">SPE Expert 2K-FA</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{/* PowerGenius is TCP-only; SPE amps connect over USB serial or an
|
||||||
|
RS232-to-Ethernet bridge, so they offer both. */}
|
||||||
|
{!isPGXL && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Connection</Label>
|
||||||
|
<Select value={pgxl.transport} onValueChange={(v) => setPgxl((s) => ({ ...s, transport: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="serial">USB (serial COM)</SelectItem>
|
||||||
|
<SelectItem value="tcp">Network (RS232-to-Ethernet)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isSerial ? (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>COM port</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={pgxl.com_port || '_'} onValueChange={(v) => setPgxl((s) => ({ ...s, com_port: v === '_' ? '' : v }))}>
|
||||||
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||||
|
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Baud</Label>
|
||||||
|
<Input type="number" min={1200} value={pgxl.baud}
|
||||||
|
onChange={(e) => setPgxl((s) => ({ ...s, baud: parseInt(e.target.value) || 115200 }))} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Host / IP</Label>
|
||||||
|
<Input value={pgxl.host ?? ''} onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
|
||||||
|
placeholder="192.168.1.70" className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>TCP port</Label>
|
||||||
|
<Input type="number" min={1} max={65535} value={pgxl.port}
|
||||||
|
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isPGXL && pgxl.enabled && <SPEStatusCard />}
|
||||||
|
{!isPGXL && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
SPE control uses the amplifier's proprietary serial protocol (OPERATE toggle + live status). Save to (re)connect. Band / power-level / antenna are still managed on the amp from the transceiver CAT.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RotatorPanel() {
|
function RotatorPanel() {
|
||||||
|
const isRG = (rotator as any).type === 'rotgenius';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Rotator"
|
title="Rotator"
|
||||||
hint={t('rot.hint')}
|
hint={isRG ? undefined : t('rot.hint')}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
|
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
|
||||||
Enable PstRotator control
|
{t('rot.enable')}
|
||||||
</label>
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('rot.type')}</Label>
|
||||||
|
{/* Switching to Rotator Genius moves the default port to its native
|
||||||
|
9006; back to PstRotator restores 12000. */}
|
||||||
|
<Select value={(rotator as any).type ?? 'pst'}
|
||||||
|
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : 12000 } as any))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||||||
|
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{isRG && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('rot.rotatorNum')}</Label>
|
||||||
|
<Select value={String((rotator as any).rotator_num ?? 1)}
|
||||||
|
onValueChange={(v) => setRotator((s) => ({ ...s, rotator_num: parseInt(v, 10) || 1 } as any))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1</SelectItem>
|
||||||
|
<SelectItem value="2">2</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label>Host</Label>
|
<Label>Host / IP</Label>
|
||||||
<Input
|
<Input
|
||||||
value={rotator.host ?? ''}
|
value={rotator.host ?? ''}
|
||||||
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
|
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
|
||||||
placeholder="127.0.0.1"
|
placeholder={isRG ? '192.168.1.60' : '127.0.0.1'}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>UDP port</Label>
|
<Label>{isRG ? 'TCP port' : 'UDP port'}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number" min={1} max={65535}
|
type="number" min={1} max={65535}
|
||||||
value={rotator.port}
|
value={rotator.port}
|
||||||
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || 12000 }))}
|
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : 12000) }))}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
{!isRG && (
|
||||||
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
This rotator supports elevation (VHF / satellite)
|
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
||||||
</label>
|
This rotator supports elevation (VHF / satellite)
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
||||||
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
||||||
@@ -2453,9 +2853,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
{!isRG && (
|
||||||
Park
|
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
||||||
</Button>
|
Park
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{rotatorTest && (
|
{rotatorTest && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -2503,6 +2905,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
|
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
|
||||||
<SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem>
|
<SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem>
|
||||||
|
<SelectItem value="flex">FlexRadio (CWX)</SelectItem>
|
||||||
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
|
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -2533,6 +2936,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : wk.engine === 'flex' ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-2">
|
||||||
|
FlexRadio keys CW through the radio's <strong>CWX</strong> keyer over the existing SmartSDR CAT connection — no WinKeyer or SmartCAT needed. It reuses the connection set in Settings → CAT, so there's nothing else to wire up here. Put a slice in CW mode. Only the speed is set from here; weight, sidetone and break-in are configured on the radio (break-in must be on for CW to actually transmit).
|
||||||
|
</p>
|
||||||
|
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
||||||
|
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||||||
|
<span aria-hidden>⚠</span>
|
||||||
|
<span>
|
||||||
|
Your CAT backend is set to <strong>{catCfg.enabled ? (catCfg.backend || 'none') : 'disabled'}</strong>. Flex CWX needs the CAT backend set to <strong>FlexRadio</strong> and connected — change it under Settings → CAT interface, otherwise sending CW will fail.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Speed (WPM)</Label>
|
||||||
|
<Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
@@ -3674,6 +4097,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
setDbMsg(p);
|
setDbMsg(p);
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Rename the CURRENT database (keeps all config), unlike New database which
|
||||||
|
// starts empty. The old file is removed on the next launch.
|
||||||
|
async function renameDb() {
|
||||||
|
try {
|
||||||
|
const p = await PickSaveDatabase();
|
||||||
|
if (!p) return;
|
||||||
|
await RenameDatabase(p);
|
||||||
|
await refreshDb();
|
||||||
|
setDbMsg(p);
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
async function resetDefault() {
|
async function resetDefault() {
|
||||||
try {
|
try {
|
||||||
await ResetDatabaseToDefault();
|
await ResetDatabaseToDefault();
|
||||||
@@ -3752,6 +4186,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
|
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
|
||||||
<Button variant="outline" size="sm" onClick={openExisting}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
|
<Button variant="outline" size="sm" onClick={openExisting}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
|
||||||
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
|
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
|
||||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
||||||
</div>
|
</div>
|
||||||
@@ -4067,6 +4502,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Checkbox checked={startEqEnd} onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }} />
|
<Checkbox checked={startEqEnd} onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }} />
|
||||||
{t('gen.startEqEnd')} <span className="text-xs text-muted-foreground">{t('gen.startEqEndHint')}</span>
|
{t('gen.startEqEnd')} <span className="text-xs text-muted-foreground">{t('gen.startEqEndHint')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={showQsoRate} onCheckedChange={(c) => { const v = !!c; setShowQsoRate(v); writeUiPref('opslog.showQsoRate', v ? '1' : '0'); }} />
|
||||||
|
{t('gen.showQsoRate')} <span className="text-xs text-muted-foreground">{t('gen.showQsoRateHint')}</span>
|
||||||
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
||||||
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
|
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
|
||||||
@@ -4196,7 +4635,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Label className="text-sm">{t('em.username')}</Label>
|
<Label className="text-sm">{t('em.username')}</Label>
|
||||||
<Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} />
|
<Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} />
|
||||||
<Label className="text-sm">{t('es.password')}</Label>
|
<Label className="text-sm">{t('es.password')}</Label>
|
||||||
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
<div className="relative">
|
||||||
|
<Input type={showSmtpPass ? 'text' : 'password'} className="h-8 pr-9" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
||||||
|
<button type="button" tabIndex={-1} onClick={() => setShowSmtpPass((v) => !v)}
|
||||||
|
title={showSmtpPass ? t('es.hidePass') : t('es.showPass')}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||||
|
disabled={!emailCfg.auth}>
|
||||||
|
{showSmtpPass ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<Label className="text-sm">{t('em.fromAddr')}</Label>
|
<Label className="text-sm">{t('em.fromAddr')}</Label>
|
||||||
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
|
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
|
||||||
<Label className="text-sm">{t('em.replyTo')}</Label>
|
<Label className="text-sm">{t('em.replyTo')}</Label>
|
||||||
@@ -4235,6 +4682,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function USCountiesPanel() {
|
||||||
|
const loaded = ulsStatus.count > 0;
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-1">{t('uscty.title')}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">{t('uscty.intro')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Required-download notice */}
|
||||||
|
<div className="rounded-md border border-warning/40 bg-warning/10 p-3 text-xs text-foreground/90 leading-relaxed">
|
||||||
|
{t('uscty.needDownload')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status + download */}
|
||||||
|
<div className="rounded-md border border-border p-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="font-medium">{t('uscty.dbStatus')}</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{loaded
|
||||||
|
? t('uscty.loaded', { n: ulsStatus.count.toLocaleString(), date: ulsStatus.updated_at ? new Date(ulsStatus.updated_at).toLocaleDateString() : '—' })
|
||||||
|
: t('uscty.notLoaded')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={downloadUls} disabled={ulsBusy}>
|
||||||
|
{ulsBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : <ArrowDown className="size-3.5 mr-1.5" />}
|
||||||
|
{loaded ? t('uscty.update') : t('uscty.download')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ulsProgress && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[11px] text-muted-foreground flex justify-between">
|
||||||
|
<span>{ulsProgress.stage}</span><span>{ulsProgress.pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full rounded bg-muted overflow-hidden">
|
||||||
|
<div className="h-full bg-primary transition-all" style={{ width: `${ulsProgress.pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ulsMsg && (
|
||||||
|
<div className={cn('text-xs', ulsMsg.ok ? 'text-success' : 'text-destructive')}>{ulsMsg.text}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Backfill existing QSOs */}
|
||||||
|
<div className="rounded-md border border-border p-3 space-y-2">
|
||||||
|
<div className="text-xs font-medium">{t('uscty.backfillTitle')}</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('uscty.backfillIntro')}</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button size="sm" variant="secondary" onClick={runBackfill} disabled={backfillBusy || !loaded}>
|
||||||
|
{backfillBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
|
||||||
|
{t('uscty.backfillRun')}
|
||||||
|
</Button>
|
||||||
|
{backfillMsg && <span className="text-xs text-muted-foreground">{backfillMsg}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Map sections to their content + icon (for placeholder).
|
// Map sections to their content + icon (for placeholder).
|
||||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||||
general: GeneralPanel,
|
general: GeneralPanel,
|
||||||
@@ -4249,8 +4758,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
'lists-modes': ModesPanel,
|
'lists-modes': ModesPanel,
|
||||||
cluster: ClusterPanel,
|
cluster: ClusterPanel,
|
||||||
udp: UDPIntegrationsPanelWrapper,
|
udp: UDPIntegrationsPanelWrapper,
|
||||||
|
// Rendered as a real element (not called as a bare function) so its own hooks
|
||||||
|
// — useState/useEffect/useI18n — get a proper component context; PANELS[x]()
|
||||||
|
// is a plain call and hook-holding panels must go through JSX like this.
|
||||||
|
adifmon: () => <ADIFMonitorPanel />,
|
||||||
|
relayauto: () => <RelayAutoPanel />,
|
||||||
backup: BackupPanel,
|
backup: BackupPanel,
|
||||||
database: DatabasePanel,
|
database: DatabasePanel,
|
||||||
|
uscounties: USCountiesPanel,
|
||||||
autostart: () => <AutostartPanelComponent />,
|
autostart: () => <AutostartPanelComponent />,
|
||||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||||
cat: CATPanel,
|
cat: CATPanel,
|
||||||
|
|||||||
@@ -0,0 +1,620 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
|
import {
|
||||||
|
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||||
|
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||||
|
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||||
|
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||||
|
|
||||||
|
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; channels?: number; labels: string[] };
|
||||||
|
type Relay = { number: number; label: string; on: boolean };
|
||||||
|
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||||
|
|
||||||
|
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 };
|
||||||
|
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
|
||||||
|
|
||||||
|
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
|
||||||
|
// the generic USB-serial board, whose channel count the user picks.
|
||||||
|
const chanCount = (d: Device): number =>
|
||||||
|
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : (RELAY_COUNT[d.type] ?? 5);
|
||||||
|
|
||||||
|
function blankDevice(): Device {
|
||||||
|
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||||
|
}
|
||||||
|
|
||||||
|
type Heading = { enabled: boolean; ok: boolean; azimuth: number };
|
||||||
|
|
||||||
|
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
||||||
|
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
||||||
|
// GoTo box, quick N/E/S/W presets, and Stop. Heading is polled by the panel and
|
||||||
|
// passed in (so the panel can also order this widget); it drives the shared
|
||||||
|
// rotator backend.
|
||||||
|
function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: RotatorProps & {
|
||||||
|
hd: Heading; refetch: () => void; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const [goto, setGoto] = useState('');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
const turn = (az: number) => {
|
||||||
|
const a = ((Math.round(az) % 360) + 360) % 360;
|
||||||
|
RotatorGoTo(a, -1).then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||||||
|
};
|
||||||
|
const poll = refetch;
|
||||||
|
|
||||||
|
const presets: [string, number][] = [['N', 0], ['E', 90], ['S', 180], ['W', 270]];
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Compass className="size-4 text-primary" />
|
||||||
|
<div className="text-sm font-semibold">{t('station.rotator')}</div>
|
||||||
|
<span className={cn('ml-auto size-2 rounded-full', hd.ok ? 'bg-success' : 'bg-warning')}
|
||||||
|
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||||||
|
</div>
|
||||||
|
<div className="p-3 flex gap-4 items-start">
|
||||||
|
<RotorCompass
|
||||||
|
bearing={bearing ?? null}
|
||||||
|
headings={hd.ok ? [hd.azimuth] : []}
|
||||||
|
centerLat={centerLat ?? null}
|
||||||
|
centerLon={centerLon ?? null}
|
||||||
|
rotorEnabled={hd.ok}
|
||||||
|
onGoto={(az) => turn(az)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
|
<div className="font-mono">
|
||||||
|
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{presets.map(([lbl, az]) => (
|
||||||
|
<button key={lbl} type="button" onClick={() => turn(az)}
|
||||||
|
className="flex-1 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:bg-muted">
|
||||||
|
{lbl}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Input value={goto} onChange={(e) => setGoto(e.target.value.replace(/[^0-9]/g, ''))}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && goto) turn(parseInt(goto, 10)); }}
|
||||||
|
placeholder="0–359" className="h-8 flex-1 min-w-0 font-mono text-sm" />
|
||||||
|
<Button size="sm" className="h-8 shrink-0" disabled={!goto} onClick={() => turn(parseInt(goto, 10))}>{t('station.go')}</Button>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" className="h-8 w-full" onClick={() => RotatorStop().then(poll).catch((e) => setErr(String(e?.message ?? e)))}>
|
||||||
|
<Square className="size-3 mr-1" /> {t('station.stop')}
|
||||||
|
</Button>
|
||||||
|
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[] };
|
||||||
|
|
||||||
|
// MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the
|
||||||
|
// Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam
|
||||||
|
// only — per-element length adjustment. Heading/state is polled by the panel.
|
||||||
|
const ELEMENT_STEP = 2; // the physical console adjusts 2 mm per press
|
||||||
|
|
||||||
|
// elementName maps an element index to a ham-radio name: 0 = reflector,
|
||||||
|
// 1 = driven element, then Director 1, 2, 3…
|
||||||
|
function elementName(i: number, t: (k: string, v?: any) => string): string {
|
||||||
|
if (i === 0) return t('station.reflector');
|
||||||
|
if (i === 1) return t('station.driven');
|
||||||
|
return `${t('station.director')} ${i - 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () => void; t: (k: string, v?: any) => string }) {
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements
|
||||||
|
const [reading, setReading] = useState(false);
|
||||||
|
const [busyEl, setBusyEl] = useState<number | null>(null);
|
||||||
|
const [editingEl, setEditingEl] = useState<number | null>(null); // element whose mm is being typed
|
||||||
|
const [editVal, setEditVal] = useState('');
|
||||||
|
const run = (p: Promise<any>) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||||||
|
const isUB = ant.type !== 'steppir';
|
||||||
|
|
||||||
|
const readLengths = useCallback(async () => {
|
||||||
|
setReading(true); setErr('');
|
||||||
|
try { setLengths(((await MotorReadElements()) ?? []) as number[]); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
finally { setReading(false); }
|
||||||
|
}, []);
|
||||||
|
// Read the current lengths once when the Ultrabeam widget mounts/connects, so
|
||||||
|
// +/- starts from the real values rather than blind.
|
||||||
|
useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]);
|
||||||
|
|
||||||
|
// Send an absolute length to one element and remember it as the new baseline.
|
||||||
|
const setLen = async (i: number, mm: number) => {
|
||||||
|
const next = Math.max(0, Math.round(mm));
|
||||||
|
const prev = lengths[i] ?? 0;
|
||||||
|
setBusyEl(i); setErr('');
|
||||||
|
setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic
|
||||||
|
try { await MotorSetElement(i, next); refetch(); }
|
||||||
|
catch (e: any) {
|
||||||
|
// Rejected by the controller — most often the element is at its travel
|
||||||
|
// limit for this band (extending on a low band). Revert the optimistic
|
||||||
|
// value so the display stays truthful, and explain the likely cause when
|
||||||
|
// the failed move was an extension.
|
||||||
|
setLengths((ls) => { const c = [...ls]; c[i] = prev; return c; });
|
||||||
|
setErr(next > prev ? t('station.atMax') : String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
finally { setBusyEl(null); }
|
||||||
|
};
|
||||||
|
// Nudge one element by ±2 mm from its current known length.
|
||||||
|
const nudge = (i: number, delta: number) => setLen(i, (lengths[i] ?? 0) + delta);
|
||||||
|
// Commit a typed exact length (click on the mm value). Lets the operator fix
|
||||||
|
// the baseline when the auto-read is off, so +/- then work reliably.
|
||||||
|
const commitEdit = (i: number) => {
|
||||||
|
const v = parseInt(editVal, 10);
|
||||||
|
setEditingEl(null);
|
||||||
|
if (!isNaN(v) && v >= 0 && v !== lengths[i]) setLen(i, v);
|
||||||
|
};
|
||||||
|
const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]];
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<AntennaIcon className="size-4 text-primary" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold truncate">{isUB ? 'Ultrabeam' : 'SteppIR'}</div>
|
||||||
|
{ant.frequency > 0 && <div className="text-[10px] text-muted-foreground font-mono">{(ant.frequency / 1000).toFixed(3)} MHz</div>}
|
||||||
|
</div>
|
||||||
|
{ant.moving && <span className="ml-auto text-[10px] font-semibold text-warning animate-pulse">{t('station.moving')}</span>}
|
||||||
|
<span className={cn('size-2 rounded-full shrink-0', ant.moving ? '' : 'ml-auto', ant.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
|
title={ant.connected ? t('station.online') : t('station.offline')} />
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.pattern')}</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{dirs.map(([d, lbl]) => (
|
||||||
|
<button key={d} type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => run(SetUltrabeamDirection(d))}
|
||||||
|
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors disabled:opacity-40',
|
||||||
|
ant.direction === d ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
||||||
|
{lbl}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => run(UltrabeamRetract())}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1.5 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||||
|
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isUB && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('station.elements')}</span>
|
||||||
|
<button type="button" onClick={readLengths} disabled={!ant.connected || reading}
|
||||||
|
className="text-[10px] text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-40" title={t('station.readLengths')}>
|
||||||
|
<RefreshCw className={cn('size-3', reading && 'animate-spin')} /> {t('station.read')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{lengths.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{/* Hide 0-length elements — an Ultrabeam reports 6 slots but a
|
||||||
|
3-element beam only uses the first few. */}
|
||||||
|
{lengths.map((mm, i) => ({ mm, i })).filter((e) => e.mm > 0).map(({ mm, i }) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
|
||||||
|
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||||
|
onClick={() => nudge(i, -ELEMENT_STEP)}
|
||||||
|
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||||
|
<Minus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
{editingEl === i ? (
|
||||||
|
<input autoFocus type="number" value={editVal}
|
||||||
|
onChange={(e) => setEditVal(e.target.value)}
|
||||||
|
onBlur={() => commitEdit(i)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(i); if (e.key === 'Escape') setEditingEl(null); }}
|
||||||
|
className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums bg-transparent border border-primary rounded px-1" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums cursor-pointer hover:underline"
|
||||||
|
title={t('station.setExactLen')}
|
||||||
|
onClick={() => { setEditingEl(i); setEditVal(String(mm)); }}>
|
||||||
|
{busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||||
|
onClick={() => nudge(i, ELEMENT_STEP)}
|
||||||
|
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">{t('station.elementsHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
|
const [status, setStatus] = useState<Record<string, DevStatus>>({});
|
||||||
|
const [editing, setEditing] = useState<Device | null>(null); // device being added/edited
|
||||||
|
const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight
|
||||||
|
const [rot, setRot] = useState<Heading>({ enabled: false, ok: false, azimuth: 0 });
|
||||||
|
const [ant, setAnt] = useState<AntStatus>({ enabled: false, type: '', connected: false, direction: 0, frequency: 0, moving: false, elements: [] });
|
||||||
|
// Widget order (rotator + device ids), drag-and-drop reorderable, persisted.
|
||||||
|
const [order, setOrder] = useState<string[]>(() => {
|
||||||
|
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
|
||||||
|
});
|
||||||
|
const dragId = useRef<string | null>(null);
|
||||||
|
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
||||||
|
|
||||||
|
const loadDevices = useCallback(async () => {
|
||||||
|
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const poll = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const s = ((await GetStationStatus()) ?? []) as DevStatus[];
|
||||||
|
setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
|
||||||
|
} catch { /* ignore transient */ }
|
||||||
|
}, []);
|
||||||
|
const pollRot = useCallback(async () => {
|
||||||
|
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
const pollAnt = useCallback(async () => {
|
||||||
|
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadDevices(); }, [loadDevices]);
|
||||||
|
useEffect(() => {
|
||||||
|
poll(); pollRot(); pollAnt();
|
||||||
|
const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [poll, pollRot, pollAnt, devices.length]);
|
||||||
|
|
||||||
|
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
|
||||||
|
// Reorder so `dragged` lands just before `target`.
|
||||||
|
const onDrop = (targetId: string) => {
|
||||||
|
const src = dragId.current; dragId.current = null;
|
||||||
|
if (!src || src === targetId) return;
|
||||||
|
const ids = widgetIds.filter((id) => id !== src);
|
||||||
|
const at = ids.indexOf(targetId);
|
||||||
|
ids.splice(at < 0 ? ids.length : at, 0, src);
|
||||||
|
persistOrder(ids);
|
||||||
|
};
|
||||||
|
|
||||||
|
const persist = async (next: Device[]) => {
|
||||||
|
setDevices(next);
|
||||||
|
try { await SaveStationDevices(next as any); } catch { /* surfaced by status */ }
|
||||||
|
await loadDevices();
|
||||||
|
poll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = async (dev: Device, relay: number, on: boolean) => {
|
||||||
|
const key = `${dev.id}:${relay}`;
|
||||||
|
setBusy((b) => ({ ...b, [key]: true }));
|
||||||
|
// Optimistic flip so the switch feels instant; the poll reconciles.
|
||||||
|
setStatus((st) => {
|
||||||
|
const d = st[dev.id]; if (!d) return st;
|
||||||
|
return { ...st, [dev.id]: { ...d, relays: d.relays.map((r) => (r.number === relay ? { ...r, on } : r)) } };
|
||||||
|
});
|
||||||
|
try { await StationSetRelay(dev.id, relay, on); } catch { /* poll will correct */ }
|
||||||
|
await poll();
|
||||||
|
setBusy((b) => ({ ...b, [key]: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!editing) return;
|
||||||
|
const d = { ...editing, name: editing.name.trim() || TYPE_LABEL[editing.type], host: editing.host.trim() };
|
||||||
|
const exists = devices.some((x) => x.id && x.id === d.id);
|
||||||
|
await persist(exists ? devices.map((x) => (x.id === d.id ? d : x)) : [...devices, d]);
|
||||||
|
setEditing(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDevice = async (id: string) => { await persist(devices.filter((x) => x.id !== id)); };
|
||||||
|
|
||||||
|
// Build the widget list (rotator first by default, then devices), then order it
|
||||||
|
// by the saved drag order — unknown ids fall to the end in their natural order.
|
||||||
|
const deviceCard = (dev: Device) => {
|
||||||
|
const st = status[dev.id];
|
||||||
|
const relays = st?.relays ?? dev.labels.map((label, i) => ({ number: i + 1, label, on: false }));
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<PlugZap className="size-4 text-primary" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold truncate">{dev.name || TYPE_LABEL[dev.type]}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground font-mono truncate">{TYPE_LABEL[dev.type]} · {dev.host}</div>
|
||||||
|
</div>
|
||||||
|
<span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
|
title={st?.connected ? t('station.online') : (st?.error || t('station.offline'))} />
|
||||||
|
<button className="text-muted-foreground hover:text-foreground" title={t('station.edit')}
|
||||||
|
onClick={() => setEditing({ ...dev, labels: [...dev.labels] })}><Pencil className="size-3.5" /></button>
|
||||||
|
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
||||||
|
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 grid grid-cols-2 gap-2">
|
||||||
|
{relays.map((r) => {
|
||||||
|
const key = `${dev.id}:${r.number}`;
|
||||||
|
const label = r.label || `${t('station.relay')} ${r.number}`;
|
||||||
|
return (
|
||||||
|
<button key={r.number} type="button" disabled={!st?.connected}
|
||||||
|
onClick={() => toggle(dev, r.number, !r.on)}
|
||||||
|
className={cn('flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors disabled:opacity-40',
|
||||||
|
r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||||||
|
<span className={cn('flex items-center justify-center size-7 rounded-md shrink-0',
|
||||||
|
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||||
|
{busy[key] ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-xs font-medium truncate">{label}</span>
|
||||||
|
<span className={cn('block text-[10px] font-semibold', r.on ? 'text-success' : 'text-muted-foreground')}>
|
||||||
|
{r.on ? t('station.on') : t('station.off')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const widgets: { id: string; node: React.ReactNode }[] = [];
|
||||||
|
if (rot.enabled) {
|
||||||
|
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||||
|
}
|
||||||
|
if (ant.enabled) {
|
||||||
|
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||||||
|
}
|
||||||
|
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||||||
|
|
||||||
|
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||||
|
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
||||||
|
const widgetIds = ordered.map((w) => w.id);
|
||||||
|
|
||||||
|
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled;
|
||||||
|
|
||||||
|
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
|
||||||
|
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
|
||||||
|
// fill however many columns are available.
|
||||||
|
const gridCols: Record<string, string> = {
|
||||||
|
auto: 'sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4',
|
||||||
|
'1': 'grid-cols-1', '2': 'grid-cols-2', '3': 'grid-cols-3', '4': 'grid-cols-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
||||||
|
{(['auto', '2', '3', '4'] as const).map((c) => (
|
||||||
|
<button key={c} type="button"
|
||||||
|
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
||||||
|
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
|
{c === 'auto' ? t('station.colsAuto') : c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
|
||||||
|
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{noDevices && !editing && (
|
||||||
|
<div className="max-w-4xl rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t('station.empty')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn('grid gap-3 items-start', gridCols[cols] ?? gridCols.auto)}>
|
||||||
|
{ordered.map((w) => (
|
||||||
|
<div key={w.id} draggable
|
||||||
|
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
||||||
|
onDrop={(e) => { e.preventDefault(); onDrop(w.id); }}
|
||||||
|
className={cn('cursor-grab active:cursor-grabbing', dragId.current === w.id && 'opacity-60')}>
|
||||||
|
{w.node}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!noDevices && (
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-2">{t('station.dragHint')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<DeviceEditor device={editing} onChange={setEditing} onSave={saveEdit} onCancel={() => setEditing(null)} t={t} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||||
|
device: Device; onChange: (d: Device) => void; onSave: () => void; onCancel: () => void; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
|
||||||
|
const setType = (type: string) => {
|
||||||
|
const channels = (type === 'denkovi' || type === 'usbrelay') ? (device.channels || 8) : undefined;
|
||||||
|
const n = chanCount({ ...device, type, channels });
|
||||||
|
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||||
|
onChange({ ...device, type, channels, labels });
|
||||||
|
};
|
||||||
|
const setChannels = (channels: number) => {
|
||||||
|
const n = chanCount({ ...device, channels });
|
||||||
|
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||||
|
onChange({ ...device, channels, labels });
|
||||||
|
};
|
||||||
|
const isKM = device.type === 'kmtronic';
|
||||||
|
const isDenkovi = device.type === 'denkovi';
|
||||||
|
const isUsbRelay = device.type === 'usbrelay';
|
||||||
|
// COM ports for the generic USB-serial relay picker.
|
||||||
|
const [serialPorts, setSerialPorts] = useState<string[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isUsbRelay) return;
|
||||||
|
ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => setSerialPorts([]));
|
||||||
|
}, [isUsbRelay]);
|
||||||
|
// Detected FTDI serials for the Denkovi picker.
|
||||||
|
const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
|
||||||
|
const [detecting, setDetecting] = useState(false);
|
||||||
|
const [detectMsg, setDetectMsg] = useState('');
|
||||||
|
const detectDenkovi = async () => {
|
||||||
|
setDetecting(true);
|
||||||
|
setDetectMsg('');
|
||||||
|
try {
|
||||||
|
const list = ((await ListDenkoviDevices()) ?? []) as string[];
|
||||||
|
setDenkoviSerials(list);
|
||||||
|
// Auto-fill when there's exactly one and nothing chosen yet.
|
||||||
|
if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] });
|
||||||
|
setDetectMsg(list.length === 0 ? t('station.detectNone') : t('station.detectFound', { n: list.length }));
|
||||||
|
} catch (e: any) { setDenkoviSerials([]); setDetectMsg(String(e?.message || e || t('station.detectNone'))); }
|
||||||
|
finally { setDetecting(false); }
|
||||||
|
};
|
||||||
|
// Connection test result for the current device config.
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
|
const testDevice = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
setTestMsg(null);
|
||||||
|
try {
|
||||||
|
const r: any = await TestStationDevice(device as any);
|
||||||
|
setTestMsg(r?.ok
|
||||||
|
? { ok: true, text: t('station.testOk', { n: r.relays }) }
|
||||||
|
: { ok: false, text: r?.error || t('station.testFail') });
|
||||||
|
} catch (e: any) { setTestMsg({ ok: false, text: String(e?.message || e || t('station.testFail')) }); }
|
||||||
|
finally { setTesting(false); }
|
||||||
|
};
|
||||||
|
useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]);
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
|
||||||
|
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.type')}</Label>
|
||||||
|
<Select value={device.type} onValueChange={setType}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||||||
|
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||||||
|
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
|
||||||
|
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.name')}</Label>
|
||||||
|
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(isDenkovi || isUsbRelay) && (
|
||||||
|
<div className="space-y-1 max-w-[10rem]">
|
||||||
|
<Label>{t('station.channels')}</Label>
|
||||||
|
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(isDenkovi ? [4, 8] : [1, 2, 4, 8, 16]).map((n) => (
|
||||||
|
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isDenkovi ? (
|
||||||
|
<div className="space-y-1 max-w-md">
|
||||||
|
<Label>{t('station.ftdiSerial')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input className="font-mono flex-1" value={device.host} placeholder="DAE0006K"
|
||||||
|
list="denkovi-serials"
|
||||||
|
onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||||
|
<datalist id="denkovi-serials">
|
||||||
|
{denkoviSerials.map((s) => <option key={s} value={s} />)}
|
||||||
|
</datalist>
|
||||||
|
<Button size="sm" variant="outline" onClick={detectDenkovi} disabled={detecting}>
|
||||||
|
{detecting ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||||
|
{t('station.detect')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
|
||||||
|
{detectMsg && <p className="text-[11px] font-medium text-muted-foreground">{detectMsg}</p>}
|
||||||
|
</div>
|
||||||
|
) : isUsbRelay ? (
|
||||||
|
<div className="space-y-1 max-w-md">
|
||||||
|
<Label>{t('station.comPort')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={device.host || '_'} onValueChange={(v) => onChange({ ...device, host: v === '_' ? '' : v })}>
|
||||||
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{serialPorts.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
||||||
|
{serialPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||||
|
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
||||||
|
<Label>{t('station.host')}</Label>
|
||||||
|
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
{isKM && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.user')}</Label>
|
||||||
|
<Input value={device.user ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, user: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.pass')}</Label>
|
||||||
|
<Input type="password" value={device.pass ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, pass: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.labels')}</Label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{device.labels.map((lab, i) => (
|
||||||
|
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
|
||||||
|
onChange={(e) => { const labels = [...device.labels]; labels[i] = e.target.value; onChange({ ...device, labels }); }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{testMsg && (
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-xs font-medium', testMsg.ok ? 'text-success' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', testMsg.ok ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{testMsg.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
|
||||||
|
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
|
||||||
|
{t('station.test')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
||||||
|
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-react';
|
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-react';
|
||||||
import { GetLogStats, GetContestRuns } from '../../wailsjs/go/main/App';
|
import { GetLogStats, GetContestRuns, GetOperators } from '../../wailsjs/go/main/App';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -23,14 +23,16 @@ import { cn } from '@/lib/utils';
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type Bucket = { key: string; count: number };
|
type Bucket = { key: string; count: number };
|
||||||
|
type BandCat = { band: string; cw: number; phone: number; data: number; total: number };
|
||||||
type Gap = { start: string; end: string; minutes: number };
|
type Gap = { start: string; end: string; minutes: number };
|
||||||
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
||||||
type Stats = {
|
type Stats = {
|
||||||
total: number; unique_calls: number; entities: number; continents: number;
|
total: number; unique_calls: number; entities: number; continents: number;
|
||||||
first_qso: string; last_qso: string;
|
first_qso: string; last_qso: string;
|
||||||
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
||||||
by_mode: Bucket[]; by_band: Bucket[]; by_operator: Bucket[]; by_station: Bucket[];
|
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||||
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||||
|
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
|
||||||
// Period / contest metrics.
|
// Period / contest metrics.
|
||||||
window_start: string; window_end: string; window_hours: number;
|
window_start: string; window_end: string; window_hours: number;
|
||||||
avg_per_hour: number; avg_per_active: number;
|
avg_per_hour: number; avg_per_active: number;
|
||||||
@@ -52,11 +54,14 @@ const nf = (n: number) => n.toLocaleString('en-US');
|
|||||||
|
|
||||||
// ── Shell ────────────────────────────────────────────────────────────────────
|
// ── Shell ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Card({ title, sub, children, className }: { title: string; sub?: string; children: React.ReactNode; className?: string }) {
|
function Card({ title, sub, children, className, accent = 'var(--chart-1)' }: { title: string; sub?: string; children: React.ReactNode; className?: string; accent?: string }) {
|
||||||
return (
|
return (
|
||||||
<section className={cn('rounded-lg border border-border bg-card p-3.5 flex flex-col min-w-0', className)}>
|
<section className={cn('rounded-xl border border-border bg-card p-3.5 flex flex-col min-w-0 shadow-sm', className)}>
|
||||||
<header className="mb-3 shrink-0">
|
<header className="mb-3 shrink-0">
|
||||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</h3>
|
<h3 className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
<span className="size-1.5 rounded-full shrink-0" style={{ background: accent }} />
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
|
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
|
||||||
</header>
|
</header>
|
||||||
{children}
|
{children}
|
||||||
@@ -64,14 +69,22 @@ function Card({ title, sub, children, className }: { title: string; sub?: string
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A headline number IS the chart — a one-bar bar chart would be noise.
|
// A headline number IS the chart — a one-bar bar chart would be noise. Each tile
|
||||||
function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
// carries an accent (a categorical chart hue or a semantic token): a soft tint
|
||||||
|
// wash + a left accent bar give it a colourful identity, while the number itself
|
||||||
|
// stays in high-contrast foreground ink so it reads on every theme.
|
||||||
|
function StatTile({ label, value, sub, accent = 'var(--chart-1)' }: { label: string; value: string; sub?: string; accent?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-card px-4 py-3 min-w-0">
|
<div className="relative overflow-hidden rounded-xl border border-border bg-card px-4 py-3 min-w-0 shadow-sm">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
|
<div className="pointer-events-none absolute inset-0 opacity-[0.08]" style={{ background: accent }} />
|
||||||
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
|
<div className="pointer-events-none absolute -right-5 -top-7 size-20 rounded-full blur-xl opacity-[0.14]" style={{ background: accent }} />
|
||||||
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
|
<div className="absolute left-0 inset-y-0 w-1" style={{ background: accent }} />
|
||||||
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
|
<div className="relative">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
|
||||||
|
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
|
||||||
|
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
|
||||||
|
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,7 +93,7 @@ function StatTile({ label, value, sub }: { label: string; value: string; sub?: s
|
|||||||
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
||||||
// on a tooltip to get a number.
|
// on a tooltip to get a number.
|
||||||
|
|
||||||
function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empty: string; share?: boolean }) {
|
function HBars({ data, max, empty, share, labelWidth = 'w-20', colorful, color = 'var(--chart-1)' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string; colorful?: boolean; color?: string }) {
|
||||||
// max is a display cap for long tails (top entities). Where EVERY row matters —
|
// max is a display cap for long tails (top entities). Where EVERY row matters —
|
||||||
// the operators of a multi-op — it is deliberately not set: a capped chart would
|
// the operators of a multi-op — it is deliberately not set: a capped chart would
|
||||||
// silently drop the 9th operator, and "who worked what" is the whole question.
|
// silently drop the 9th operator, and "who worked what" is the whole question.
|
||||||
@@ -90,13 +103,13 @@ function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empt
|
|||||||
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5 min-w-0">
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
{top.map((d) => (
|
{top.map((d, i) => (
|
||||||
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
||||||
<span className="w-20 shrink-0 truncate text-[11px] text-muted-foreground text-right">{d.key}</span>
|
<span className={`${labelWidth} shrink-0 truncate text-[11px] text-muted-foreground text-right`} title={d.key}>{d.key}</span>
|
||||||
<div className="flex-1 min-w-0 h-[14px] flex items-center">
|
<div className="flex-1 min-w-0 h-[14px] flex items-center">
|
||||||
<div
|
<div
|
||||||
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
||||||
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: colorful ? `var(--chart-${(i % 8) + 1})` : color }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
|
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
|
||||||
@@ -115,7 +128,7 @@ function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empt
|
|||||||
// Sorting bands by COUNT would destroy the band-plan reading; the order is the
|
// Sorting bands by COUNT would destroy the band-plan reading; the order is the
|
||||||
// information.
|
// information.
|
||||||
|
|
||||||
function VBars({ data, empty, height = 150 }: { data: Bucket[]; empty: string; height?: number }) {
|
function VBars({ data, empty, height = 150, showValues, colorful }: { data: Bucket[]; empty: string; height?: number; showValues?: boolean; colorful?: boolean }) {
|
||||||
const peak = Math.max(1, ...data.map((d) => d.count));
|
const peak = Math.max(1, ...data.map((d) => d.count));
|
||||||
if (data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
if (data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
// Thin the x labels once the bars get narrow. A label under EVERY one of 48
|
// Thin the x labels once the bars get narrow. A label under EVERY one of 48
|
||||||
@@ -129,12 +142,13 @@ function VBars({ data, empty, height = 150 }: { data: Bucket[]; empty: string; h
|
|||||||
{data.map((d, i) => (
|
{data.map((d, i) => (
|
||||||
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||||
title={`${d.key} — ${nf(d.count)}`}>
|
title={`${d.key} — ${nf(d.count)}`}>
|
||||||
<span className="text-[9px] text-muted-foreground mb-0.5 opacity-0 group-hover:opacity-100 transition-opacity tabular-nums whitespace-nowrap">
|
<span className={cn('text-[9px] font-semibold mb-0.5 tabular-nums whitespace-nowrap',
|
||||||
|
showValues ? 'text-foreground' : 'text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity')}>
|
||||||
{nf(d.count)}
|
{nf(d.count)}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
className="w-full rounded-t-[4px] transition-[height] duration-300"
|
className="w-full rounded-t-[4px] transition-[height] duration-300"
|
||||||
style={{ height: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
style={{ height: `${Math.max(2, (d.count / peak) * 100)}%`, background: colorful ? `var(--chart-${(i % 8) + 1})` : 'var(--chart-1)' }}
|
||||||
/>
|
/>
|
||||||
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap overflow-visible">
|
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap overflow-visible">
|
||||||
{i % every === 0 ? d.key : ''}
|
{i % every === 0 ? d.key : ''}
|
||||||
@@ -145,6 +159,47 @@ function VBars({ data, empty, height = 150 }: { data: Bucket[]; empty: string; h
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-band mode split (CW / phone / data) as stacked vertical bars. The three
|
||||||
|
// categories are the subject → categorical colour in a fixed order, matching the
|
||||||
|
// mode colours used elsewhere (CW gold, phone green, data blue).
|
||||||
|
const BAND_SPLIT = [
|
||||||
|
{ key: 'cw', label: 'CW', color: 'var(--chart-3)' },
|
||||||
|
{ key: 'phone', label: 'Phone', color: 'var(--chart-2)' },
|
||||||
|
{ key: 'data', label: 'Data', color: 'var(--chart-1)' },
|
||||||
|
] as const;
|
||||||
|
function StackedBandBars({ data, empty, height = 150 }: { data: BandCat[]; empty: string; height?: number }) {
|
||||||
|
if (!data || data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
const peak = Math.max(1, ...data.map((d) => d.total));
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-end gap-[3px] min-w-0" style={{ height }}>
|
||||||
|
{data.map((d) => (
|
||||||
|
<div key={d.band} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||||
|
title={`${d.band} — CW ${nf(d.cw)} · Phone ${nf(d.phone)} · Data ${nf(d.data)} (${nf(d.total)})`}>
|
||||||
|
<span className="text-[9px] font-semibold mb-0.5 tabular-nums text-foreground">{nf(d.total)}</span>
|
||||||
|
<div className="w-full flex flex-col justify-end rounded-t-[4px] overflow-hidden"
|
||||||
|
style={{ height: `${Math.max(2, (d.total / peak) * 100)}%` }}>
|
||||||
|
{BAND_SPLIT.map((s) => {
|
||||||
|
const v = d[s.key];
|
||||||
|
if (!v) return null;
|
||||||
|
return <div key={s.key} style={{ flexGrow: v, flexBasis: 0, minHeight: 1, background: s.color }} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">{d.band}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-center gap-3 text-[10px] text-muted-foreground">
|
||||||
|
{BAND_SPLIT.map((s) => (
|
||||||
|
<span key={s.key} className="inline-flex items-center gap-1">
|
||||||
|
<span className="size-2.5 rounded-[3px]" style={{ background: s.color }} /> {s.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Contest rate, stacked by operator ────────────────────────────────────────
|
// ── Contest rate, stacked by operator ────────────────────────────────────────
|
||||||
// The categories (the operators) ARE the subject, so this is categorical colour,
|
// The categories (the operators) ARE the subject, so this is categorical colour,
|
||||||
// in the FIXED order the backend sends (busiest first). An operator therefore
|
// in the FIXED order the backend sends (busiest first). An operator therefore
|
||||||
@@ -450,6 +505,38 @@ function periodRange(p: Period, from: string, to: string): [string, string] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActivityCard — the "activity over time" chart with a granularity selector. Its
|
||||||
|
// own state, module-scoped so a parent re-render doesn't reset the choice. The
|
||||||
|
// timeline is the chronological month series; day/week/month/year are the cyclical
|
||||||
|
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
|
||||||
|
const ACT_GRAN = [
|
||||||
|
{ key: 'timeline', tkey: 'stats.granTimeline' },
|
||||||
|
{ key: 'day', tkey: 'stats.granDay' },
|
||||||
|
{ key: 'week', tkey: 'stats.granWeek' },
|
||||||
|
{ key: 'month', tkey: 'stats.granMonth' },
|
||||||
|
{ key: 'year', tkey: 'stats.granYear' },
|
||||||
|
] as const;
|
||||||
|
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
||||||
|
const [g, setG] = useState<string>('timeline');
|
||||||
|
const series = g === 'day' ? stats.by_hour : g === 'week' ? stats.by_day7 : g === 'month' ? stats.by_day30 : g === 'year' ? stats.by_month12 : [];
|
||||||
|
return (
|
||||||
|
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
|
||||||
|
<div className="mb-2 flex flex-wrap gap-1">
|
||||||
|
{ACT_GRAN.map((o) => (
|
||||||
|
<button key={o.key} type="button" onClick={() => setG(o.key)}
|
||||||
|
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||||
|
g === o.key ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t(o.tkey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{g === 'timeline'
|
||||||
|
? <AreaTrend data={stats.by_month} empty={empty} />
|
||||||
|
: <VBars data={series} empty={empty} showValues height={160} />}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
||||||
|
|
||||||
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
||||||
@@ -490,10 +577,14 @@ export function StatsPanel() {
|
|||||||
// that contest AND lets the window derive from its own span — no date typing.
|
// that contest AND lets the window derive from its own span — no date typing.
|
||||||
const [runs, setRuns] = useState<ContestRun[]>([]);
|
const [runs, setRuns] = useState<ContestRun[]>([]);
|
||||||
const [contest, setContest] = useState('');
|
const [contest, setContest] = useState('');
|
||||||
|
// Operator picker: '' = all operators, "—" = station owner (empty OPERATOR).
|
||||||
|
const [operators, setOperators] = useState<string[]>([]);
|
||||||
|
const [operator, setOperator] = useState('');
|
||||||
|
|
||||||
useEffect(() => { GetContestRuns().then((r: any) => setRuns((r ?? []) as ContestRun[])).catch(() => {}); }, []);
|
useEffect(() => { GetContestRuns().then((r: any) => setRuns((r ?? []) as ContestRun[])).catch(() => {}); }, []);
|
||||||
|
useEffect(() => { GetOperators().then((o: any) => setOperators((o ?? []) as string[])).catch(() => {}); }, []);
|
||||||
|
|
||||||
const load = async (p: Period = period, f = from, t2 = to, c = contest) => {
|
const load = async (p: Period = period, f = from, t2 = to, c = contest, op = operator) => {
|
||||||
// A contest defines its own window (its first→last QSO), so we send no dates
|
// A contest defines its own window (its first→last QSO), so we send no dates
|
||||||
// with it — the backend derives them. Sending a period as well would be two
|
// with it — the backend derives them. Sending a period as well would be two
|
||||||
// filters fighting over the same axis.
|
// filters fighting over the same axis.
|
||||||
@@ -501,16 +592,17 @@ export function StatsPanel() {
|
|||||||
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
|
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
|
||||||
setBusy(true); setErr('');
|
setBusy(true); setErr('');
|
||||||
try {
|
try {
|
||||||
const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0)) as any;
|
const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0, op)) as any;
|
||||||
// Harden the boundary: a Go nil slice arrives as JSON null, and a single
|
// Harden the boundary: a Go nil slice arrives as JSON null, and a single
|
||||||
// .length on null unmounts the whole React tree — a white screen. Normalise
|
// .length on null unmounts the whole React tree — a white screen. Normalise
|
||||||
// once, here, rather than guarding at every use site and missing one.
|
// once, here, rather than guarding at every use site and missing one.
|
||||||
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
||||||
setStats({
|
setStats({
|
||||||
...raw,
|
...raw,
|
||||||
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_operator: arr(raw.by_operator),
|
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_band_category: arr(raw.by_band_category), by_operator: arr(raw.by_operator),
|
||||||
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
||||||
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
||||||
|
by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
|
||||||
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
||||||
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
||||||
} as Stats);
|
} as Stats);
|
||||||
@@ -522,9 +614,9 @@ export function StatsPanel() {
|
|||||||
// are set — otherwise every keystroke in the date box would re-scan the log.
|
// are set — otherwise every keystroke in the date box would re-scan the log.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!contest && period === 'custom' && !(from && to)) return;
|
if (!contest && period === 'custom' && !(from && to)) return;
|
||||||
load(period, from, to, contest);
|
load(period, from, to, contest, operator);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [period, from, to, contest]);
|
}, [period, from, to, contest, operator]);
|
||||||
|
|
||||||
// The rate / off-air block belongs to a CONTEST-shaped effort, and nowhere else.
|
// The rate / off-air block belongs to a CONTEST-shaped effort, and nowhere else.
|
||||||
// Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break"
|
// Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break"
|
||||||
@@ -569,6 +661,20 @@ export function StatsPanel() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{/* Operator picker — narrows every stat (totals, DXCC, top countries,
|
||||||
|
continent split, rate) to one operator from the log. "—" = the
|
||||||
|
station owner (QSOs logged with no OPERATOR set). */}
|
||||||
|
{operators.length > 1 && (
|
||||||
|
<select value={operator} onChange={(e) => setOperator(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[180px]"
|
||||||
|
title={t('stats.operatorTip')}>
|
||||||
|
<option value="">{t('stats.allOperators')}</option>
|
||||||
|
{operators.map((op) => (
|
||||||
|
<option key={op} value={op}>{op === '—' ? t('stats.stationOwner') : op}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={cn('inline-flex rounded-md border border-border overflow-hidden', contest && 'opacity-40 pointer-events-none')}>
|
<div className={cn('inline-flex rounded-md border border-border overflow-hidden', contest && 'opacity-40 pointer-events-none')}>
|
||||||
{([
|
{([
|
||||||
['all', t('stats.pAll')], ['ytd', t('stats.pYTD')],
|
['all', t('stats.pAll')], ['ytd', t('stats.pYTD')],
|
||||||
@@ -610,12 +716,12 @@ export function StatsPanel() {
|
|||||||
|
|
||||||
{/* Headline figures: stat tiles, not a grouped bar chart. */}
|
{/* Headline figures: stat tiles, not a grouped bar chart. */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
||||||
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} />
|
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} accent="var(--chart-1)" />
|
||||||
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} />
|
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} accent="var(--chart-2)" />
|
||||||
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" />
|
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" accent="var(--chart-5)" />
|
||||||
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" />
|
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" accent="var(--chart-8)" />
|
||||||
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
||||||
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} />
|
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} accent="var(--success)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
|
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
|
||||||
@@ -704,43 +810,41 @@ export function StatsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||||
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')}>
|
<Card title={t('stats.bandSplit')} sub={t('stats.bandSplitSub')} accent="var(--chart-3)">
|
||||||
<VBars data={stats.by_band} empty={empty} />
|
<StackedBandBars data={stats.by_band_category} empty={empty} />
|
||||||
</Card>
|
</Card>
|
||||||
<Card title={t('stats.byMode')}>
|
<Card title={t('stats.byMode')} accent="var(--chart-2)">
|
||||||
<HBars data={stats.by_mode} max={8} empty={empty} />
|
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2">
|
<ActivityCard stats={stats} t={t} empty={empty} />
|
||||||
<AreaTrend data={stats.by_month} empty={empty} />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
||||||
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||||
instead of truncating. */}
|
instead of truncating. */}
|
||||||
<Card title={t('stats.byOperator')} sub={t('stats.byOperatorSub')}>
|
<Card title={t('stats.byOperator')} accent="var(--chart-5)">
|
||||||
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
||||||
<HBars data={stats.by_operator} empty={empty} share />
|
<HBars data={stats.by_operator} empty={empty} share color="var(--chart-5)" />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}>
|
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')} accent="var(--chart-6)">
|
||||||
<Donut data={stats.by_continent} empty={empty} />
|
<Donut data={stats.by_continent} empty={empty} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title={t('stats.topEntities')}>
|
<Card title={t('stats.topEntities')} accent="var(--chart-4)">
|
||||||
<HBars data={stats.top_entities} max={12} empty={empty} />
|
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" color="var(--chart-4)" />
|
||||||
</Card>
|
</Card>
|
||||||
<div className="flex flex-col gap-2.5 min-w-0">
|
<div className="flex flex-col gap-2.5 min-w-0">
|
||||||
<Card title={t('stats.confirmations')} className="flex-1">
|
<Card title={t('stats.confirmations')} className="flex-1" accent="var(--success)">
|
||||||
<div className="flex flex-col gap-3 justify-center flex-1">
|
<div className="flex flex-col gap-3 justify-center flex-1">
|
||||||
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
|
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
|
||||||
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
|
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
|
||||||
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
|
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title={t('stats.byStation')}>
|
<Card title={t('stats.byStation')} accent="var(--chart-8)">
|
||||||
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
|
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
|
||||||
<HBars data={stats.by_station} empty={empty} share />
|
<HBars data={stats.by_station} empty={empty} share color="var(--chart-8)" />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface Props {
|
|||||||
wpm: number;
|
wpm: number;
|
||||||
macros: WKMacro[];
|
macros: WKMacro[];
|
||||||
sent: string; // text echoed back by the keyer as it transmits
|
sent: string; // text echoed back by the keyer as it transmits
|
||||||
source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer)
|
source: 'winkeyer' | 'icom' | 'flex'; // CW output engine (chosen in Settings → CW Keyer)
|
||||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||||
onSetBreakIn?: (mode: number) => void;
|
onSetBreakIn?: (mode: number) => void;
|
||||||
onSelectPort: (p: string) => void;
|
onSelectPort: (p: string) => void;
|
||||||
@@ -101,14 +101,16 @@ export function WinkeyerPanel({
|
|||||||
<Radio className="size-4 text-primary shrink-0" />
|
<Radio className="size-4 text-primary shrink-0" />
|
||||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||||
{source === 'icom' ? 'Icom CW' : 'WinKeyer'}
|
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : 'WinKeyer'}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{source === 'icom' ? (
|
{source === 'icom' || source === 'flex' ? (
|
||||||
<span className="text-[11px] font-medium text-muted-foreground">
|
<span className="text-[11px] font-medium text-muted-foreground">
|
||||||
{connected ? t('wkp.civReady') : t('wkp.civOffline')}
|
{source === 'flex'
|
||||||
|
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||||
|
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||||
</span>
|
</span>
|
||||||
) : !connected ? (
|
) : !connected ? (
|
||||||
<>
|
<>
|
||||||
@@ -183,7 +185,7 @@ export function WinkeyerPanel({
|
|||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
||||||
{t('wkp.cwText')}
|
{t('wkp.cwText')}
|
||||||
{source === 'winkeyer' && (
|
{(source === 'winkeyer' || source === 'flex') && (
|
||||||
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
||||||
title={t('wkp.sendOnTypeHint')}>
|
title={t('wkp.sendOnTypeHint')}>
|
||||||
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||||
|
|||||||
@@ -64,7 +64,17 @@ export function Combobox({
|
|||||||
// Focus selects the text so a keystroke replaces it — but does NOT
|
// Focus selects the text so a keystroke replaces it — but does NOT
|
||||||
// open the list (so tabbing in doesn't pop the dropdown).
|
// open the list (so tabbing in doesn't pop the dropdown).
|
||||||
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
||||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (commitOnType) onChange(e.target.value); }}
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setQuery(v);
|
||||||
|
setOpen(true);
|
||||||
|
// Commit-on-type pushes the value live to the parent (so a CW macro sent
|
||||||
|
// without leaving the field uses what was just typed). With free text that's
|
||||||
|
// any input; a restricted field (allowFreeText=false) commits ONLY a value
|
||||||
|
// that's actually in the list, so a half-typed or invalid report never
|
||||||
|
// becomes the committed value — blur then reverts the leftover text.
|
||||||
|
if (commitOnType && (allowFreeText || options.some((o) => o.toLowerCase() === v.trim().toLowerCase()))) onChange(v);
|
||||||
|
}}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
||||||
|
|||||||
@@ -6,11 +6,32 @@
|
|||||||
// back to the DB copy and re-seed the cache.
|
// back to the DB copy and re-seed the cache.
|
||||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// The DB copy is ALREADY per-profile (the backend prefixes every ui.* key with
|
||||||
|
// the active profile id). The localStorage cache, however, is one namespace for
|
||||||
|
// the whole WebView, so without scoping it too a profile switch would keep
|
||||||
|
// serving the previous profile's cached layout and the correct per-profile DB
|
||||||
|
// value would never win. lsScope makes the cache per-profile as well; it's set
|
||||||
|
// once the active profile is known and updated on every profile switch.
|
||||||
|
let lsScope = '';
|
||||||
|
|
||||||
|
// setGridPrefsProfile scopes the localStorage cache to a profile. Call it before
|
||||||
|
// the grids read their state (at startup) and again whenever the active profile
|
||||||
|
// changes so each profile keeps its own column layout / widths.
|
||||||
|
export function setGridPrefsProfile(id: number | string | null | undefined): void {
|
||||||
|
lsScope = id == null || id === '' ? '' : `p${id}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
|
||||||
|
// GetUIPref/SetUIPref is left untouched — the backend already scopes it.
|
||||||
|
function lsKey(key: string): string {
|
||||||
|
return lsScope + key;
|
||||||
|
}
|
||||||
|
|
||||||
// loadLocal reads the cached column state synchronously (used in onGridReady
|
// loadLocal reads the cached column state synchronously (used in onGridReady
|
||||||
// to apply instantly, before the async DB round-trip).
|
// to apply instantly, before the async DB round-trip).
|
||||||
export function loadLocal(key: string): any[] | null {
|
export function loadLocal(key: string): any[] | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(key);
|
const raw = localStorage.getItem(lsKey(key));
|
||||||
const v = raw ? JSON.parse(raw) : null;
|
const v = raw ? JSON.parse(raw) : null;
|
||||||
return Array.isArray(v) ? v : null;
|
return Array.isArray(v) ? v : null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -29,15 +50,16 @@ export async function loadRemote(key: string): Promise<any[] | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveState write-throughs to both the cache and the DB (fire-and-forget).
|
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
|
||||||
|
// the cache key is profile-scoped; the DB key is scoped by the backend.
|
||||||
export function saveState(key: string, state: any[]) {
|
export function saveState(key: string, state: any[]) {
|
||||||
const json = JSON.stringify(state);
|
const json = JSON.stringify(state);
|
||||||
try { localStorage.setItem(key, json); } catch { /* quota / private mode */ }
|
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
|
||||||
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedLocal writes a value into the cache without touching the DB (used after
|
// seedLocal writes a value into the cache without touching the DB (used after
|
||||||
// hydrating the cache from the DB on a fresh machine).
|
// hydrating the cache from the DB on a fresh machine).
|
||||||
export function seedLocal(key: string, state: any[]) {
|
export function seedLocal(key: string, state: any[]) {
|
||||||
try { localStorage.setItem(key, JSON.stringify(state)); } catch { /* ignore */ }
|
try { localStorage.setItem(lsKey(key), JSON.stringify(state)); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|||||||
+152
-42
File diff suppressed because one or more lines are too long
@@ -14,3 +14,25 @@ export function sMeterRST(s: number, overDb: number, mode?: string): string {
|
|||||||
if (overDb > 0) return `59+${Math.ceil(overDb / 5) * 5}`;
|
if (overDb > 0) return `59+${Math.ceil(overDb / 5) * 5}`;
|
||||||
return `5${strength}`;
|
return `5${strength}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RST dropdown lists, shared by the entry form and the QSO editor so both offer
|
||||||
|
// the same per-mode choices (Settings → Modes → RST report lists).
|
||||||
|
export type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
|
||||||
|
|
||||||
|
// rstCategory maps an ADIF mode to its RST family (phone / cw / digital).
|
||||||
|
export function rstCategory(mode: string): keyof RSTLists {
|
||||||
|
const m = (mode || '').toUpperCase();
|
||||||
|
const digital = ['FT8', 'FT4', 'JT65', 'JT9', 'JS8', 'Q65', 'MSK144', 'FST4', 'FST4W', 'MFSK', 'OLIVIA', 'JT4', 'WSPR'];
|
||||||
|
if (digital.includes(m)) return 'digital';
|
||||||
|
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
|
||||||
|
return 'phone';
|
||||||
|
}
|
||||||
|
|
||||||
|
// rstOptions returns the valid report choices for a mode from the user's
|
||||||
|
// editable lists, with a tiny fallback before they load.
|
||||||
|
export function rstOptions(mode: string, lists: RSTLists): string[] {
|
||||||
|
const cat = rstCategory(mode);
|
||||||
|
const l = lists[cat];
|
||||||
|
if (l && l.length) return l;
|
||||||
|
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
|
||||||
import { writeUiPref } from './uiPref';
|
import { writeUiPref } from './uiPref';
|
||||||
|
import { GetUIPref } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
// Theme system. Each choice maps to a `data-theme` value on <html> that the
|
// Theme system. Each choice maps to a `data-theme` value on <html> that the
|
||||||
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
||||||
@@ -49,13 +50,44 @@ export function useTheme(): Ctx { return useContext(ThemeCtx); }
|
|||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored());
|
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored());
|
||||||
|
// Set once the operator changes the theme by hand, so the self-heal below
|
||||||
|
// never clobbers a fresh choice with a value it read a moment earlier.
|
||||||
|
const userPicked = useRef(false);
|
||||||
|
|
||||||
const setTheme = useCallback((t: ThemeChoice) => {
|
const setTheme = useCallback((t: ThemeChoice) => {
|
||||||
|
userPicked.current = true;
|
||||||
setThemeState(t);
|
setThemeState(t);
|
||||||
applyThemeToDom(t);
|
applyThemeToDom(t);
|
||||||
writeUiPref(LS_KEY, t);
|
writeUiPref(LS_KEY, t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Self-heal the persisted theme. The synchronous boot read (localStorage) can
|
||||||
|
// miss it when the WebView cleared its storage, OR when syncPortablePrefs ran
|
||||||
|
// while the backend was still starting (settings store not wired yet → GetUIPref
|
||||||
|
// returned "" with no error, so nothing was restored) — the "restart lands on
|
||||||
|
// the light theme sometimes" bug. Re-read the portable pref from the DB once the
|
||||||
|
// backend is up and apply it, retrying briefly to ride out a slow startup.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let tries = 0;
|
||||||
|
const load = () => {
|
||||||
|
tries += 1;
|
||||||
|
GetUIPref(LS_KEY).then((raw) => {
|
||||||
|
if (cancelled || userPicked.current) return;
|
||||||
|
const v = raw as ThemeChoice;
|
||||||
|
if (v && ALL.includes(v)) {
|
||||||
|
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
|
||||||
|
applyThemeToDom(v); // idempotent — safe to call unconditionally
|
||||||
|
setThemeState(v);
|
||||||
|
return; // restored
|
||||||
|
}
|
||||||
|
if (tries < 8) window.setTimeout(load, 300); // empty (unset or backend not ready yet) → retry
|
||||||
|
}).catch(() => { if (!cancelled && tries < 8) window.setTimeout(load, 300); });
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// While in 'auto', re-resolve when the OS light/dark preference flips.
|
// While in 'auto', re-resolve when the OS light/dark preference flips.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (theme !== 'auto') return;
|
if (theme !== 'auto') return;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||||
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||||
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
||||||
|
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
|
||||||
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
|
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
|
||||||
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
||||||
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||||
@@ -26,6 +27,15 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||||
'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)
|
||||||
|
// Cluster filter selections — restored on reopen.
|
||||||
|
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||||
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
|
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||||
|
'opslog.activeTab', // last selected tab
|
||||||
|
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||||
|
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||||
|
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||||
|
// through this global path would fight that per-profile scoping.
|
||||||
];
|
];
|
||||||
|
|
||||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||||
|
|||||||
@@ -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.19.6';
|
export const APP_VERSION = '0.20.6';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+87
-10
@@ -11,6 +11,7 @@ import {awardref} from '../models';
|
|||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
|
import {spe} from '../models';
|
||||||
import {solar} from '../models';
|
import {solar} from '../models';
|
||||||
import {winkeyer} from '../models';
|
import {winkeyer} from '../models';
|
||||||
import {alerts} from '../models';
|
import {alerts} from '../models';
|
||||||
@@ -38,6 +39,8 @@ export function ApplyAwardImport(arg1:string,arg2:Record<string, string>):Promis
|
|||||||
|
|
||||||
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
|
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 AudioMonitorActive():Promise<boolean>;
|
export function AudioMonitorActive():Promise<boolean>;
|
||||||
@@ -62,14 +65,14 @@ export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Reco
|
|||||||
|
|
||||||
export function AwardsFolder():Promise<string>;
|
export function AwardsFolder():Promise<string>;
|
||||||
|
|
||||||
|
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
|
||||||
|
|
||||||
export function BrowseExecutable():Promise<string>;
|
export function BrowseExecutable():Promise<string>;
|
||||||
|
|
||||||
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
||||||
|
|
||||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||||
|
|
||||||
export function CWDecoderRunning():Promise<boolean>;
|
|
||||||
|
|
||||||
export function ChatAvailable():Promise<boolean>;
|
export function ChatAvailable():Promise<boolean>;
|
||||||
|
|
||||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||||
@@ -138,16 +141,24 @@ export function DisconnectClusterServer(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
||||||
|
|
||||||
|
export function DismissAwardUpdate(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DownloadAllReferenceLists():Promise<string>;
|
export function DownloadAllReferenceLists():Promise<string>;
|
||||||
|
|
||||||
|
export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||||
|
|
||||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||||
|
|
||||||
export function DownloadLoTWUsers():Promise<number>;
|
export function DownloadLoTWUsers():Promise<number>;
|
||||||
|
|
||||||
|
export function DownloadULSCounties():Promise<void>;
|
||||||
|
|
||||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||||
|
|
||||||
|
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
||||||
|
|
||||||
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
|
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
|
||||||
@@ -156,6 +167,8 @@ export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):
|
|||||||
|
|
||||||
export function ExportAward(arg1:string):Promise<string>;
|
export function ExportAward(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function ExportAwardForCatalog(arg1:string,arg2:number):Promise<string>;
|
||||||
|
|
||||||
export function ExportAwards():Promise<string>;
|
export function ExportAwards():Promise<string>;
|
||||||
|
|
||||||
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
|
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
|
||||||
@@ -178,8 +191,12 @@ export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexBackspaceCW(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexMox(arg1:boolean):Promise<void>;
|
export function FlexMox(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSendCW(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
|
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
|
||||||
@@ -210,6 +227,8 @@ export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetKeySpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMic(arg1:number):Promise<void>;
|
export function FlexSetMic(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMicProfile(arg1:string):Promise<void>;
|
export function FlexSetMicProfile(arg1:string):Promise<void>;
|
||||||
@@ -234,6 +253,10 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetRIT(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetRITFreq(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
||||||
@@ -258,8 +281,16 @@ export function FlexSetWNB(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetWNBLevel(arg1:number):Promise<void>;
|
export function FlexSetWNBLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetXIT(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetXITFreq(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexStopCW():Promise<void>;
|
||||||
|
|
||||||
export function FlexTune(arg1:boolean):Promise<void>;
|
export function FlexTune(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
|
||||||
|
|
||||||
export function GetActiveProfile():Promise<profile.Profile>;
|
export function GetActiveProfile():Promise<profile.Profile>;
|
||||||
|
|
||||||
export function GetAlertEmailTo():Promise<string>;
|
export function GetAlertEmailTo():Promise<string>;
|
||||||
@@ -282,6 +313,8 @@ export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
|
|||||||
|
|
||||||
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
|
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
|
||||||
|
|
||||||
|
export function GetAwardUpdates():Promise<Array<main.AwardUpdate>>;
|
||||||
|
|
||||||
export function GetAwards():Promise<Array<award.Result>>;
|
export function GetAwards():Promise<Array<award.Result>>;
|
||||||
|
|
||||||
export function GetBackupSettings():Promise<main.BackupSettings>;
|
export function GetBackupSettings():Promise<main.BackupSettings>;
|
||||||
@@ -290,10 +323,10 @@ export function GetCATSettings():Promise<main.CATSettings>;
|
|||||||
|
|
||||||
export function GetCATState():Promise<cat.RigState>;
|
export function GetCATState():Promise<cat.RigState>;
|
||||||
|
|
||||||
export function GetCWDecoderPitch():Promise<number>;
|
|
||||||
|
|
||||||
export function GetCatalogCodes():Promise<Array<string>>;
|
export function GetCatalogCodes():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
||||||
|
|
||||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||||
|
|
||||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||||
@@ -330,13 +363,15 @@ export function GetIcomState():Promise<cat.IcomTXState>;
|
|||||||
|
|
||||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||||
|
|
||||||
|
export function GetLiveStations():Promise<Array<main.LiveStation>>;
|
||||||
|
|
||||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||||
|
|
||||||
export function GetLogFilePath():Promise<string>;
|
export function GetLogFilePath():Promise<string>;
|
||||||
|
|
||||||
export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number):Promise<qso.Stats>;
|
export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number,arg5:string):Promise<qso.Stats>;
|
||||||
|
|
||||||
export function GetLogbookRevision():Promise<string>;
|
export function GetLogbookRevision():Promise<string>;
|
||||||
|
|
||||||
@@ -348,6 +383,8 @@ export function GetOfflineStatus():Promise<main.OfflineStatus>;
|
|||||||
|
|
||||||
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
||||||
|
|
||||||
|
export function GetOperators():Promise<Array<string>>;
|
||||||
|
|
||||||
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
||||||
|
|
||||||
export function GetPGXLStatus():Promise<powergenius.Status>;
|
export function GetPGXLStatus():Promise<powergenius.Status>;
|
||||||
@@ -360,18 +397,30 @@ export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
|||||||
|
|
||||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||||
|
|
||||||
|
export function GetQSORate():Promise<main.QSORate>;
|
||||||
|
|
||||||
|
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
||||||
|
|
||||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||||
|
|
||||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||||
|
|
||||||
|
export function GetSPEStatus():Promise<spe.Status>;
|
||||||
|
|
||||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||||
|
|
||||||
|
export function GetSlotStats():Promise<qso.SlotStats>;
|
||||||
|
|
||||||
export function GetSolarData():Promise<solar.Data>;
|
export function GetSolarData():Promise<solar.Data>;
|
||||||
|
|
||||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||||
|
|
||||||
|
export function GetStationDevices():Promise<Array<main.StationDevice>>;
|
||||||
|
|
||||||
export function GetStationSettings():Promise<main.StationSettings>;
|
export function GetStationSettings():Promise<main.StationSettings>;
|
||||||
|
|
||||||
|
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
||||||
|
|
||||||
export function GetTelemetryEnabled():Promise<boolean>;
|
export function GetTelemetryEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function GetUIPref(arg1:string):Promise<string>;
|
export function GetUIPref(arg1:string):Promise<string>;
|
||||||
@@ -380,6 +429,8 @@ export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
|||||||
|
|
||||||
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
||||||
|
|
||||||
|
export function GetWhatsNew():Promise<Array<main.ChangelogEntry>>;
|
||||||
|
|
||||||
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||||
|
|
||||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||||
@@ -498,6 +549,8 @@ export function ListContests():Promise<Array<contest.Def>>;
|
|||||||
|
|
||||||
export function ListCountries():Promise<Array<string>>;
|
export function ListCountries():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function ListDenkoviDevices():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
||||||
|
|
||||||
export function ListProfiles():Promise<Array<profile.Profile>>;
|
export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||||
@@ -512,12 +565,18 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
|
|||||||
|
|
||||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||||
|
|
||||||
|
export function LiveLastQSOAgeSec():Promise<number>;
|
||||||
|
|
||||||
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||||
|
|
||||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||||
|
|
||||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
|
export function MotorReadElements():Promise<Array<number>>;
|
||||||
|
|
||||||
|
export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function MoveDatabase(arg1:string):Promise<void>;
|
export function MoveDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function NetActivate(arg1:string):Promise<qso.QSO>;
|
export function NetActivate(arg1:string):Promise<qso.QSO>;
|
||||||
@@ -566,6 +625,8 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
|
|||||||
|
|
||||||
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function PickADIFMonitorFile():Promise<string>;
|
||||||
|
|
||||||
export function PickAudioFolder():Promise<string>;
|
export function PickAudioFolder():Promise<string>;
|
||||||
|
|
||||||
export function PickBackupFolder():Promise<string>;
|
export function PickBackupFolder():Promise<string>;
|
||||||
@@ -620,6 +681,8 @@ export function QSOAudioRestart():Promise<boolean>;
|
|||||||
|
|
||||||
export function QuitApp():Promise<void>;
|
export function QuitApp():Promise<void>;
|
||||||
|
|
||||||
|
export function RecomputeAllAwardRefs():Promise<number>;
|
||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
export function RefreshSolar():Promise<void>;
|
export function RefreshSolar():Promise<void>;
|
||||||
@@ -628,6 +691,8 @@ export function ReloadUDPIntegrations():Promise<Array<string>>;
|
|||||||
|
|
||||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function RenameDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
||||||
|
|
||||||
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
||||||
@@ -654,8 +719,16 @@ export function RotatorStop():Promise<void>;
|
|||||||
|
|
||||||
export function RunBackupNow():Promise<string>;
|
export function RunBackupNow():Promise<string>;
|
||||||
|
|
||||||
|
export function SPESetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SPESetPower(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SPESetPowerLevel(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SaveADIFFile():Promise<string>;
|
export function SaveADIFFile():Promise<string>;
|
||||||
|
|
||||||
|
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
|
||||||
|
|
||||||
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
||||||
|
|
||||||
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
||||||
@@ -700,8 +773,12 @@ export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
|||||||
|
|
||||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||||
|
|
||||||
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||||
@@ -728,8 +805,6 @@ export function SetCATFrequency(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function SetCATMode(arg1:string):Promise<void>;
|
export function SetCATMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||||
@@ -748,9 +823,7 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function StartCWDecoder():Promise<void>;
|
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||||
|
|
||||||
export function StopCWDecoder():Promise<void>;
|
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
@@ -776,8 +849,12 @@ export function TestQRZUpload():Promise<string>;
|
|||||||
|
|
||||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
||||||
|
|
||||||
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function ULSStatus():Promise<main.ULSStatusResult>;
|
||||||
|
|
||||||
export function UltrabeamRetract():Promise<void>;
|
export function UltrabeamRetract():Promise<void>;
|
||||||
|
|
||||||
export function UnlockSecrets(arg1:string):Promise<void>;
|
export function UnlockSecrets(arg1:string):Promise<void>;
|
||||||
|
|||||||
+172
-20
@@ -34,6 +34,10 @@ export function ApplyAwardPreset(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ApplyAwardUpdate(arg1) {
|
||||||
|
return window['go']['main']['App']['ApplyAwardUpdate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
@@ -82,6 +86,10 @@ export function AwardsFolder() {
|
|||||||
return window['go']['main']['App']['AwardsFolder']();
|
return window['go']['main']['App']['AwardsFolder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BackfillUSCounties() {
|
||||||
|
return window['go']['main']['App']['BackfillUSCounties']();
|
||||||
|
}
|
||||||
|
|
||||||
export function BrowseExecutable() {
|
export function BrowseExecutable() {
|
||||||
return window['go']['main']['App']['BrowseExecutable']();
|
return window['go']['main']['App']['BrowseExecutable']();
|
||||||
}
|
}
|
||||||
@@ -94,10 +102,6 @@ export function BulkUpdateQSL(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CWDecoderRunning() {
|
|
||||||
return window['go']['main']['App']['CWDecoderRunning']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatAvailable() {
|
export function ChatAvailable() {
|
||||||
return window['go']['main']['App']['ChatAvailable']();
|
return window['go']['main']['App']['ChatAvailable']();
|
||||||
}
|
}
|
||||||
@@ -234,10 +238,18 @@ export function DiscoverFlexRadios() {
|
|||||||
return window['go']['main']['App']['DiscoverFlexRadios']();
|
return window['go']['main']['App']['DiscoverFlexRadios']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DismissAwardUpdate(arg1) {
|
||||||
|
return window['go']['main']['App']['DismissAwardUpdate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DownloadAllReferenceLists() {
|
export function DownloadAllReferenceLists() {
|
||||||
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadAndApplyUpdate(arg1) {
|
||||||
|
return window['go']['main']['App']['DownloadAndApplyUpdate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DownloadClublogCty() {
|
export function DownloadClublogCty() {
|
||||||
return window['go']['main']['App']['DownloadClublogCty']();
|
return window['go']['main']['App']['DownloadClublogCty']();
|
||||||
}
|
}
|
||||||
@@ -250,10 +262,18 @@ export function DownloadLoTWUsers() {
|
|||||||
return window['go']['main']['App']['DownloadLoTWUsers']();
|
return window['go']['main']['App']['DownloadLoTWUsers']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadULSCounties() {
|
||||||
|
return window['go']['main']['App']['DownloadULSCounties']();
|
||||||
|
}
|
||||||
|
|
||||||
export function DuplicateProfile(arg1, arg2) {
|
export function DuplicateProfile(arg1, arg2) {
|
||||||
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ExplainAward(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function ExportADIF(arg1, arg2) {
|
export function ExportADIF(arg1, arg2) {
|
||||||
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
|
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -270,6 +290,10 @@ export function ExportAward(arg1) {
|
|||||||
return window['go']['main']['App']['ExportAward'](arg1);
|
return window['go']['main']['App']['ExportAward'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ExportAwardForCatalog(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['ExportAwardForCatalog'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function ExportAwards() {
|
export function ExportAwards() {
|
||||||
return window['go']['main']['App']['ExportAwards']();
|
return window['go']['main']['App']['ExportAwards']();
|
||||||
}
|
}
|
||||||
@@ -314,10 +338,18 @@ export function FlexApplyBandAntenna(arg1) {
|
|||||||
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
|
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexBackspaceCW(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexBackspaceCW'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexMox(arg1) {
|
export function FlexMox(arg1) {
|
||||||
return window['go']['main']['App']['FlexMox'](arg1);
|
return window['go']['main']['App']['FlexMox'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSendCW(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSendCW'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetAGCMode(arg1) {
|
export function FlexSetAGCMode(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
|
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
|
||||||
}
|
}
|
||||||
@@ -378,6 +410,10 @@ export function FlexSetFilter(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
|
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetKeySpeed(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetKeySpeed'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetMic(arg1) {
|
export function FlexSetMic(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetMic'](arg1);
|
return window['go']['main']['App']['FlexSetMic'](arg1);
|
||||||
}
|
}
|
||||||
@@ -426,6 +462,14 @@ export function FlexSetProcessorLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
|
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetRIT(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetRIT'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetRITFreq(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetRITFreq'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetRXAntenna(arg1) {
|
export function FlexSetRXAntenna(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
|
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
|
||||||
}
|
}
|
||||||
@@ -474,10 +518,26 @@ export function FlexSetWNBLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetWNBLevel'](arg1);
|
return window['go']['main']['App']['FlexSetWNBLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetXIT(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetXIT'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetXITFreq(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexStopCW() {
|
||||||
|
return window['go']['main']['App']['FlexStopCW']();
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexTune(arg1) {
|
export function FlexTune(arg1) {
|
||||||
return window['go']['main']['App']['FlexTune'](arg1);
|
return window['go']['main']['App']['FlexTune'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetADIFMonitor() {
|
||||||
|
return window['go']['main']['App']['GetADIFMonitor']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetActiveProfile() {
|
export function GetActiveProfile() {
|
||||||
return window['go']['main']['App']['GetActiveProfile']();
|
return window['go']['main']['App']['GetActiveProfile']();
|
||||||
}
|
}
|
||||||
@@ -522,6 +582,10 @@ export function GetAwardStats(arg1) {
|
|||||||
return window['go']['main']['App']['GetAwardStats'](arg1);
|
return window['go']['main']['App']['GetAwardStats'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAwardUpdates() {
|
||||||
|
return window['go']['main']['App']['GetAwardUpdates']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAwards() {
|
export function GetAwards() {
|
||||||
return window['go']['main']['App']['GetAwards']();
|
return window['go']['main']['App']['GetAwards']();
|
||||||
}
|
}
|
||||||
@@ -538,14 +602,14 @@ export function GetCATState() {
|
|||||||
return window['go']['main']['App']['GetCATState']();
|
return window['go']['main']['App']['GetCATState']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetCWDecoderPitch() {
|
|
||||||
return window['go']['main']['App']['GetCWDecoderPitch']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GetCatalogCodes() {
|
export function GetCatalogCodes() {
|
||||||
return window['go']['main']['App']['GetCatalogCodes']();
|
return window['go']['main']['App']['GetCatalogCodes']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetChangelog() {
|
||||||
|
return window['go']['main']['App']['GetChangelog']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetChatHistory(arg1) {
|
export function GetChatHistory(arg1) {
|
||||||
return window['go']['main']['App']['GetChatHistory'](arg1);
|
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||||
}
|
}
|
||||||
@@ -618,6 +682,10 @@ export function GetListsSettings() {
|
|||||||
return window['go']['main']['App']['GetListsSettings']();
|
return window['go']['main']['App']['GetListsSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLiveStations() {
|
||||||
|
return window['go']['main']['App']['GetLiveStations']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetLiveStatusEnabled() {
|
export function GetLiveStatusEnabled() {
|
||||||
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
||||||
}
|
}
|
||||||
@@ -630,8 +698,8 @@ export function GetLogFilePath() {
|
|||||||
return window['go']['main']['App']['GetLogFilePath']();
|
return window['go']['main']['App']['GetLogFilePath']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetLogStats(arg1, arg2, arg3, arg4) {
|
export function GetLogStats(arg1, arg2, arg3, arg4, arg5) {
|
||||||
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4);
|
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4, arg5);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetLogbookRevision() {
|
export function GetLogbookRevision() {
|
||||||
@@ -654,6 +722,10 @@ export function GetOnlineOperators() {
|
|||||||
return window['go']['main']['App']['GetOnlineOperators']();
|
return window['go']['main']['App']['GetOnlineOperators']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetOperators() {
|
||||||
|
return window['go']['main']['App']['GetOperators']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPGXLSettings() {
|
export function GetPGXLSettings() {
|
||||||
return window['go']['main']['App']['GetPGXLSettings']();
|
return window['go']['main']['App']['GetPGXLSettings']();
|
||||||
}
|
}
|
||||||
@@ -678,6 +750,14 @@ export function GetQSO(arg1) {
|
|||||||
return window['go']['main']['App']['GetQSO'](arg1);
|
return window['go']['main']['App']['GetQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetQSORate() {
|
||||||
|
return window['go']['main']['App']['GetQSORate']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetRelayAuto() {
|
||||||
|
return window['go']['main']['App']['GetRelayAuto']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetRotatorHeading() {
|
export function GetRotatorHeading() {
|
||||||
return window['go']['main']['App']['GetRotatorHeading']();
|
return window['go']['main']['App']['GetRotatorHeading']();
|
||||||
}
|
}
|
||||||
@@ -686,10 +766,18 @@ export function GetRotatorSettings() {
|
|||||||
return window['go']['main']['App']['GetRotatorSettings']();
|
return window['go']['main']['App']['GetRotatorSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSPEStatus() {
|
||||||
|
return window['go']['main']['App']['GetSPEStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetSecretStatus() {
|
export function GetSecretStatus() {
|
||||||
return window['go']['main']['App']['GetSecretStatus']();
|
return window['go']['main']['App']['GetSecretStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSlotStats() {
|
||||||
|
return window['go']['main']['App']['GetSlotStats']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetSolarData() {
|
export function GetSolarData() {
|
||||||
return window['go']['main']['App']['GetSolarData']();
|
return window['go']['main']['App']['GetSolarData']();
|
||||||
}
|
}
|
||||||
@@ -698,10 +786,18 @@ export function GetStartupStatus() {
|
|||||||
return window['go']['main']['App']['GetStartupStatus']();
|
return window['go']['main']['App']['GetStartupStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetStationDevices() {
|
||||||
|
return window['go']['main']['App']['GetStationDevices']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetStationSettings() {
|
export function GetStationSettings() {
|
||||||
return window['go']['main']['App']['GetStationSettings']();
|
return window['go']['main']['App']['GetStationSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetStationStatus() {
|
||||||
|
return window['go']['main']['App']['GetStationStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetTelemetryEnabled() {
|
export function GetTelemetryEnabled() {
|
||||||
return window['go']['main']['App']['GetTelemetryEnabled']();
|
return window['go']['main']['App']['GetTelemetryEnabled']();
|
||||||
}
|
}
|
||||||
@@ -718,6 +814,10 @@ export function GetUltrabeamStatus() {
|
|||||||
return window['go']['main']['App']['GetUltrabeamStatus']();
|
return window['go']['main']['App']['GetUltrabeamStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetWhatsNew() {
|
||||||
|
return window['go']['main']['App']['GetWhatsNew']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetWinkeyerSettings() {
|
export function GetWinkeyerSettings() {
|
||||||
return window['go']['main']['App']['GetWinkeyerSettings']();
|
return window['go']['main']['App']['GetWinkeyerSettings']();
|
||||||
}
|
}
|
||||||
@@ -954,6 +1054,10 @@ export function ListCountries() {
|
|||||||
return window['go']['main']['App']['ListCountries']();
|
return window['go']['main']['App']['ListCountries']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListDenkoviDevices() {
|
||||||
|
return window['go']['main']['App']['ListDenkoviDevices']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListOperatingTree() {
|
export function ListOperatingTree() {
|
||||||
return window['go']['main']['App']['ListOperatingTree']();
|
return window['go']['main']['App']['ListOperatingTree']();
|
||||||
}
|
}
|
||||||
@@ -982,6 +1086,10 @@ export function ListUDPIntegrations() {
|
|||||||
return window['go']['main']['App']['ListUDPIntegrations']();
|
return window['go']['main']['App']['ListUDPIntegrations']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LiveLastQSOAgeSec() {
|
||||||
|
return window['go']['main']['App']['LiveLastQSOAgeSec']();
|
||||||
|
}
|
||||||
|
|
||||||
export function LoTWUserInfo(arg1) {
|
export function LoTWUserInfo(arg1) {
|
||||||
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
||||||
}
|
}
|
||||||
@@ -994,6 +1102,14 @@ export function LookupCallsign(arg1) {
|
|||||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function MotorReadElements() {
|
||||||
|
return window['go']['main']['App']['MotorReadElements']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MotorSetElement(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['MotorSetElement'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function MoveDatabase(arg1) {
|
export function MoveDatabase(arg1) {
|
||||||
return window['go']['main']['App']['MoveDatabase'](arg1);
|
return window['go']['main']['App']['MoveDatabase'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1090,6 +1206,10 @@ export function PGXLSetFanMode(arg1) {
|
|||||||
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PickADIFMonitorFile() {
|
||||||
|
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||||
|
}
|
||||||
|
|
||||||
export function PickAudioFolder() {
|
export function PickAudioFolder() {
|
||||||
return window['go']['main']['App']['PickAudioFolder']();
|
return window['go']['main']['App']['PickAudioFolder']();
|
||||||
}
|
}
|
||||||
@@ -1198,6 +1318,10 @@ export function QuitApp() {
|
|||||||
return window['go']['main']['App']['QuitApp']();
|
return window['go']['main']['App']['QuitApp']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RecomputeAllAwardRefs() {
|
||||||
|
return window['go']['main']['App']['RecomputeAllAwardRefs']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RefreshCtyDat() {
|
export function RefreshCtyDat() {
|
||||||
return window['go']['main']['App']['RefreshCtyDat']();
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
}
|
}
|
||||||
@@ -1214,6 +1338,10 @@ export function RemovePassphrase(arg1) {
|
|||||||
return window['go']['main']['App']['RemovePassphrase'](arg1);
|
return window['go']['main']['App']['RemovePassphrase'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RenameDatabase(arg1) {
|
||||||
|
return window['go']['main']['App']['RenameDatabase'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function RenderEQSL(arg1, arg2) {
|
export function RenderEQSL(arg1, arg2) {
|
||||||
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
|
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -1266,10 +1394,26 @@ export function RunBackupNow() {
|
|||||||
return window['go']['main']['App']['RunBackupNow']();
|
return window['go']['main']['App']['RunBackupNow']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SPESetOperate(arg1) {
|
||||||
|
return window['go']['main']['App']['SPESetOperate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SPESetPower(arg1) {
|
||||||
|
return window['go']['main']['App']['SPESetPower'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SPESetPowerLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['SPESetPowerLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveADIFFile() {
|
export function SaveADIFFile() {
|
||||||
return window['go']['main']['App']['SaveADIFFile']();
|
return window['go']['main']['App']['SaveADIFFile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveADIFMonitor(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveADIFMonitor'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveAlertRule(arg1) {
|
export function SaveAlertRule(arg1) {
|
||||||
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1358,10 +1502,18 @@ export function SaveQSLDefaults(arg1) {
|
|||||||
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveRelayAuto(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1) {
|
export function SaveRotatorSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveStationDevices(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveStationDevices'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveStationSettings(arg1) {
|
export function SaveStationSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1414,10 +1566,6 @@ export function SetCATMode(arg1) {
|
|||||||
return window['go']['main']['App']['SetCATMode'](arg1);
|
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetCWDecoderPitch(arg1) {
|
|
||||||
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1) {
|
export function SetClublogCtyEnabled(arg1) {
|
||||||
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1454,12 +1602,8 @@ export function SetUltrabeamDirection(arg1) {
|
|||||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StartCWDecoder() {
|
export function StationSetRelay(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['StartCWDecoder']();
|
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
||||||
}
|
|
||||||
|
|
||||||
export function StopCWDecoder() {
|
|
||||||
return window['go']['main']['App']['StopCWDecoder']();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SwitchCATRig(arg1) {
|
export function SwitchCATRig(arg1) {
|
||||||
@@ -1510,10 +1654,18 @@ export function TestRotator(arg1) {
|
|||||||
return window['go']['main']['App']['TestRotator'](arg1);
|
return window['go']['main']['App']['TestRotator'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestStationDevice(arg1) {
|
||||||
|
return window['go']['main']['App']['TestStationDevice'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function TestUltrabeam(arg1) {
|
export function TestUltrabeam(arg1) {
|
||||||
return window['go']['main']['App']['TestUltrabeam'](arg1);
|
return window['go']['main']['App']['TestUltrabeam'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ULSStatus() {
|
||||||
|
return window['go']['main']['App']['ULSStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function UltrabeamRetract() {
|
export function UltrabeamRetract() {
|
||||||
return window['go']['main']['App']['UltrabeamRetract']();
|
return window['go']['main']['App']['UltrabeamRetract']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,9 +260,7 @@ export namespace award {
|
|||||||
pattern: string;
|
pattern: string;
|
||||||
leading_str?: string;
|
leading_str?: string;
|
||||||
trailing_str?: string;
|
trailing_str?: string;
|
||||||
multi?: boolean;
|
|
||||||
dynamic?: boolean;
|
dynamic?: boolean;
|
||||||
add_prefixes?: string[];
|
|
||||||
or_rules?: OrRule[];
|
or_rules?: OrRule[];
|
||||||
dxcc_filter: number[];
|
dxcc_filter: number[];
|
||||||
valid_bands?: string[];
|
valid_bands?: string[];
|
||||||
@@ -274,6 +272,8 @@ export namespace award {
|
|||||||
export_credit_granted?: boolean;
|
export_credit_granted?: boolean;
|
||||||
total: number;
|
total: number;
|
||||||
builtin: boolean;
|
builtin: boolean;
|
||||||
|
version?: number;
|
||||||
|
user_edited?: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Def(source);
|
return new Def(source);
|
||||||
@@ -300,9 +300,7 @@ export namespace award {
|
|||||||
this.pattern = source["pattern"];
|
this.pattern = source["pattern"];
|
||||||
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.multi = source["multi"];
|
|
||||||
this.dynamic = source["dynamic"];
|
this.dynamic = source["dynamic"];
|
||||||
this.add_prefixes = source["add_prefixes"];
|
|
||||||
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"];
|
||||||
@@ -314,6 +312,116 @@ export namespace award {
|
|||||||
this.export_credit_granted = source["export_credit_granted"];
|
this.export_credit_granted = source["export_credit_granted"];
|
||||||
this.total = source["total"];
|
this.total = source["total"];
|
||||||
this.builtin = source["builtin"];
|
this.builtin = source["builtin"];
|
||||||
|
this.version = source["version"];
|
||||||
|
this.user_edited = source["user_edited"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Rejected {
|
||||||
|
candidate: string;
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Rejected(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.candidate = source["candidate"];
|
||||||
|
this.reason = source["reason"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Step {
|
||||||
|
rule: string;
|
||||||
|
field: string;
|
||||||
|
match_by?: string;
|
||||||
|
exact?: boolean;
|
||||||
|
pattern?: string;
|
||||||
|
field_value?: string;
|
||||||
|
candidates?: string[];
|
||||||
|
kept?: string[];
|
||||||
|
rejected?: Rejected[];
|
||||||
|
skipped?: boolean;
|
||||||
|
error?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Step(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.rule = source["rule"];
|
||||||
|
this.field = source["field"];
|
||||||
|
this.match_by = source["match_by"];
|
||||||
|
this.exact = source["exact"];
|
||||||
|
this.pattern = source["pattern"];
|
||||||
|
this.field_value = source["field_value"];
|
||||||
|
this.candidates = source["candidates"];
|
||||||
|
this.kept = source["kept"];
|
||||||
|
this.rejected = this.convertValues(source["rejected"], Rejected);
|
||||||
|
this.skipped = source["skipped"];
|
||||||
|
this.error = source["error"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Explanation {
|
||||||
|
code: string;
|
||||||
|
in_scope: boolean;
|
||||||
|
scope_error?: string;
|
||||||
|
predefined: boolean;
|
||||||
|
ref_count: number;
|
||||||
|
steps: Step[];
|
||||||
|
manual?: string[];
|
||||||
|
result: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Explanation(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.code = source["code"];
|
||||||
|
this.in_scope = source["in_scope"];
|
||||||
|
this.scope_error = source["scope_error"];
|
||||||
|
this.predefined = source["predefined"];
|
||||||
|
this.ref_count = source["ref_count"];
|
||||||
|
this.steps = this.convertValues(source["steps"], Step);
|
||||||
|
this.manual = source["manual"];
|
||||||
|
this.result = source["result"];
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
@@ -365,6 +473,7 @@ export namespace award {
|
|||||||
this.validated_bands = source["validated_bands"];
|
this.validated_bands = source["validated_bands"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Result {
|
export class Result {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -613,6 +722,10 @@ export namespace cat {
|
|||||||
anf_level: number;
|
anf_level: number;
|
||||||
wnb: boolean;
|
wnb: boolean;
|
||||||
wnb_level: number;
|
wnb_level: number;
|
||||||
|
rit: boolean;
|
||||||
|
rit_freq: number;
|
||||||
|
xit: boolean;
|
||||||
|
xit_freq: number;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
cw_speed: number;
|
cw_speed: number;
|
||||||
cw_pitch: number;
|
cw_pitch: number;
|
||||||
@@ -676,6 +789,10 @@ export namespace cat {
|
|||||||
this.anf_level = source["anf_level"];
|
this.anf_level = source["anf_level"];
|
||||||
this.wnb = source["wnb"];
|
this.wnb = source["wnb"];
|
||||||
this.wnb_level = source["wnb_level"];
|
this.wnb_level = source["wnb_level"];
|
||||||
|
this.rit = source["rit"];
|
||||||
|
this.rit_freq = source["rit_freq"];
|
||||||
|
this.xit = source["xit"];
|
||||||
|
this.xit_freq = source["xit_freq"];
|
||||||
this.mode = source["mode"];
|
this.mode = source["mode"];
|
||||||
this.cw_speed = source["cw_speed"];
|
this.cw_speed = source["cw_speed"];
|
||||||
this.cw_pitch = source["cw_pitch"];
|
this.cw_pitch = source["cw_pitch"];
|
||||||
@@ -1179,6 +1296,55 @@ export namespace lotwusers {
|
|||||||
|
|
||||||
export namespace main {
|
export namespace main {
|
||||||
|
|
||||||
|
export class ADIFWatchFile {
|
||||||
|
path: string;
|
||||||
|
enabled: boolean;
|
||||||
|
offset: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ADIFWatchFile(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.path = source["path"];
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.offset = source["offset"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ADIFMonitorConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
files: ADIFWatchFile[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ADIFMonitorConfig(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.files = this.convertValues(source["files"], ADIFWatchFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class AntGeniusSettings {
|
export class AntGeniusSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -1267,6 +1433,38 @@ export namespace main {
|
|||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class AwardExplain {
|
||||||
|
qso: qso.QSO;
|
||||||
|
explanation: award.Explanation;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AwardExplain(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.qso = this.convertValues(source["qso"], qso.QSO);
|
||||||
|
this.explanation = this.convertValues(source["explanation"], award.Explanation);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
export class AwardImportPreviewEntry {
|
export class AwardImportPreviewEntry {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1408,6 +1606,40 @@ export namespace main {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class AwardUpdate {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AwardUpdate(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.code = source["code"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.from = source["from"];
|
||||||
|
this.to = source["to"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class BackfillUSCountiesResult {
|
||||||
|
scanned: number;
|
||||||
|
county: number;
|
||||||
|
grid: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new BackfillUSCountiesResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.scanned = source["scanned"];
|
||||||
|
this.county = source["county"];
|
||||||
|
this.grid = source["grid"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class BackupSettings {
|
export class BackupSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
folder: string;
|
folder: string;
|
||||||
@@ -1496,6 +1728,24 @@ export namespace main {
|
|||||||
this.path = source["path"];
|
this.path = source["path"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class ChangelogEntry {
|
||||||
|
version: string;
|
||||||
|
date: string;
|
||||||
|
en: string[];
|
||||||
|
fr: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ChangelogEntry(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.version = source["version"];
|
||||||
|
this.date = source["date"];
|
||||||
|
this.en = source["en"];
|
||||||
|
this.fr = source["fr"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ChatMessage {
|
export class ChatMessage {
|
||||||
id: number;
|
id: number;
|
||||||
operator: string;
|
operator: string;
|
||||||
@@ -1822,6 +2072,32 @@ export namespace main {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class LiveStation {
|
||||||
|
operator: string;
|
||||||
|
station: string;
|
||||||
|
freq_hz: number;
|
||||||
|
band: string;
|
||||||
|
mode: string;
|
||||||
|
online: boolean;
|
||||||
|
version: string;
|
||||||
|
age_sec: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new LiveStation(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.operator = source["operator"];
|
||||||
|
this.station = source["station"];
|
||||||
|
this.freq_hz = source["freq_hz"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.online = source["online"];
|
||||||
|
this.version = source["version"];
|
||||||
|
this.age_sec = source["age_sec"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class LoTWUsersStatus {
|
export class LoTWUsersStatus {
|
||||||
count: number;
|
count: number;
|
||||||
updated?: string;
|
updated?: string;
|
||||||
@@ -1903,8 +2179,12 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
export class PGXLSettings {
|
export class PGXLSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
type: string;
|
||||||
|
transport: string;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
com_port: string;
|
||||||
|
baud: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new PGXLSettings(source);
|
return new PGXLSettings(source);
|
||||||
@@ -1913,8 +2193,12 @@ export namespace main {
|
|||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.transport = source["transport"];
|
||||||
this.host = source["host"];
|
this.host = source["host"];
|
||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
|
this.com_port = source["com_port"];
|
||||||
|
this.baud = source["baud"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class POTAUnmatched {
|
export class POTAUnmatched {
|
||||||
@@ -2146,6 +2430,79 @@ export namespace main {
|
|||||||
this.pickable = source["pickable"];
|
this.pickable = source["pickable"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class QSORate {
|
||||||
|
last10: number;
|
||||||
|
last60: number;
|
||||||
|
team_last10: number;
|
||||||
|
team_last60: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new QSORate(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.last10 = source["last10"];
|
||||||
|
this.last60 = source["last60"];
|
||||||
|
this.team_last10 = source["team_last10"];
|
||||||
|
this.team_last60 = source["team_last60"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class RelayAutoRule {
|
||||||
|
device_id: string;
|
||||||
|
relay: number;
|
||||||
|
mode: string;
|
||||||
|
freq_lo_khz: number;
|
||||||
|
freq_hi_khz: number;
|
||||||
|
bands: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RelayAutoRule(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.device_id = source["device_id"];
|
||||||
|
this.relay = source["relay"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.freq_lo_khz = source["freq_lo_khz"];
|
||||||
|
this.freq_hi_khz = source["freq_hi_khz"];
|
||||||
|
this.bands = source["bands"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class RelayAutoConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
rules: RelayAutoRule[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RelayAutoConfig(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.rules = this.convertValues(source["rules"], RelayAutoRule);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class RotatorHeading {
|
export class RotatorHeading {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -2166,9 +2523,11 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
export class RotatorSettings {
|
export class RotatorSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
type: string;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
has_elevation: boolean;
|
has_elevation: boolean;
|
||||||
|
rotator_num: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new RotatorSettings(source);
|
return new RotatorSettings(source);
|
||||||
@@ -2177,9 +2536,11 @@ export namespace main {
|
|||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
|
this.type = source["type"];
|
||||||
this.host = source["host"];
|
this.host = source["host"];
|
||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
this.has_elevation = source["has_elevation"];
|
this.has_elevation = source["has_elevation"];
|
||||||
|
this.rotator_num = source["rotator_num"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class SecretStatus {
|
export class SecretStatus {
|
||||||
@@ -2200,6 +2561,7 @@ export namespace main {
|
|||||||
call: string;
|
call: string;
|
||||||
band: string;
|
band: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
|
pota_ref?: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new SpotQuery(source);
|
return new SpotQuery(source);
|
||||||
@@ -2210,6 +2572,7 @@ export namespace main {
|
|||||||
this.call = source["call"];
|
this.call = source["call"];
|
||||||
this.band = source["band"];
|
this.band = source["band"];
|
||||||
this.mode = source["mode"];
|
this.mode = source["mode"];
|
||||||
|
this.pota_ref = source["pota_ref"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class SpotStatus {
|
export class SpotStatus {
|
||||||
@@ -2220,6 +2583,8 @@ export namespace main {
|
|||||||
continent?: string;
|
continent?: string;
|
||||||
status: string;
|
status: string;
|
||||||
worked_call: boolean;
|
worked_call: boolean;
|
||||||
|
new_county: boolean;
|
||||||
|
new_pota: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new SpotStatus(source);
|
return new SpotStatus(source);
|
||||||
@@ -2234,6 +2599,8 @@ export namespace main {
|
|||||||
this.continent = source["continent"];
|
this.continent = source["continent"];
|
||||||
this.status = source["status"];
|
this.status = source["status"];
|
||||||
this.worked_call = source["worked_call"];
|
this.worked_call = source["worked_call"];
|
||||||
|
this.new_county = source["new_county"];
|
||||||
|
this.new_pota = source["new_pota"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class StartupStatus {
|
export class StartupStatus {
|
||||||
@@ -2252,6 +2619,88 @@ export namespace main {
|
|||||||
this.db_path = source["db_path"];
|
this.db_path = source["db_path"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class StationDevice {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
user?: string;
|
||||||
|
pass?: string;
|
||||||
|
channels?: number;
|
||||||
|
labels: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new StationDevice(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.user = source["user"];
|
||||||
|
this.pass = source["pass"];
|
||||||
|
this.channels = source["channels"];
|
||||||
|
this.labels = source["labels"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class StationRelay {
|
||||||
|
number: number;
|
||||||
|
label: string;
|
||||||
|
on: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new StationRelay(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.number = source["number"];
|
||||||
|
this.label = source["label"];
|
||||||
|
this.on = source["on"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class StationDeviceStatus {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
connected: boolean;
|
||||||
|
error?: string;
|
||||||
|
relays: StationRelay[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new StationDeviceStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.connected = source["connected"];
|
||||||
|
this.error = source["error"];
|
||||||
|
this.relays = this.convertValues(source["relays"], StationRelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
export class StationInfoComputed {
|
export class StationInfoComputed {
|
||||||
country: string;
|
country: string;
|
||||||
dxcc: number;
|
dxcc: number;
|
||||||
@@ -2274,6 +2723,7 @@ export namespace main {
|
|||||||
this.lon = source["lon"];
|
this.lon = source["lon"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StationSettings {
|
export class StationSettings {
|
||||||
callsign: string;
|
callsign: string;
|
||||||
operator: string;
|
operator: string;
|
||||||
@@ -2296,12 +2746,47 @@ export namespace main {
|
|||||||
this.my_pota_ref = source["my_pota_ref"];
|
this.my_pota_ref = source["my_pota_ref"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class StationTestResult {
|
||||||
|
ok: boolean;
|
||||||
|
relays: number;
|
||||||
|
error?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new StationTestResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.ok = source["ok"];
|
||||||
|
this.relays = source["relays"];
|
||||||
|
this.error = source["error"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ULSStatusResult {
|
||||||
|
count: number;
|
||||||
|
updated_at: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ULSStatusResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.count = source["count"];
|
||||||
|
this.updated_at = source["updated_at"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class UltrabeamSettings {
|
export class UltrabeamSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
type: string;
|
||||||
|
transport: string;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
com: string;
|
||||||
|
baud: number;
|
||||||
follow: boolean;
|
follow: boolean;
|
||||||
step_khz: number;
|
step_khz: number;
|
||||||
|
tx_inhibit: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new UltrabeamSettings(source);
|
return new UltrabeamSettings(source);
|
||||||
@@ -2310,19 +2795,26 @@ export namespace main {
|
|||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.transport = source["transport"];
|
||||||
this.host = source["host"];
|
this.host = source["host"];
|
||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
|
this.com = source["com"];
|
||||||
|
this.baud = source["baud"];
|
||||||
this.follow = source["follow"];
|
this.follow = source["follow"];
|
||||||
this.step_khz = source["step_khz"];
|
this.step_khz = source["step_khz"];
|
||||||
|
this.tx_inhibit = source["tx_inhibit"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class UltrabeamStatusInfo {
|
export class UltrabeamStatusInfo {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
type: string;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
direction: number;
|
direction: number;
|
||||||
frequency: number;
|
frequency: number;
|
||||||
band: number;
|
band: number;
|
||||||
moving: boolean;
|
moving: boolean;
|
||||||
|
elements: number[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new UltrabeamStatusInfo(source);
|
return new UltrabeamStatusInfo(source);
|
||||||
@@ -2331,11 +2823,13 @@ export namespace main {
|
|||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
|
this.type = source["type"];
|
||||||
this.connected = source["connected"];
|
this.connected = source["connected"];
|
||||||
this.direction = source["direction"];
|
this.direction = source["direction"];
|
||||||
this.frequency = source["frequency"];
|
this.frequency = source["frequency"];
|
||||||
this.band = source["band"];
|
this.band = source["band"];
|
||||||
this.moving = source["moving"];
|
this.moving = source["moving"];
|
||||||
|
this.elements = source["elements"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class UpdateInfo {
|
export class UpdateInfo {
|
||||||
@@ -2343,6 +2837,7 @@ export namespace main {
|
|||||||
latest: string;
|
latest: string;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
|
download_url: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new UpdateInfo(source);
|
return new UpdateInfo(source);
|
||||||
@@ -2354,6 +2849,7 @@ export namespace main {
|
|||||||
this.latest = source["latest"];
|
this.latest = source["latest"];
|
||||||
this.available = source["available"];
|
this.available = source["available"];
|
||||||
this.url = source["url"];
|
this.url = source["url"];
|
||||||
|
this.download_url = source["download_url"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class WKMacro {
|
export class WKMacro {
|
||||||
@@ -2949,6 +3445,26 @@ export namespace qslcard {
|
|||||||
|
|
||||||
export namespace qso {
|
export namespace qso {
|
||||||
|
|
||||||
|
export class BandCategory {
|
||||||
|
band: string;
|
||||||
|
cw: number;
|
||||||
|
phone: number;
|
||||||
|
data: number;
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new BandCategory(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.band = source["band"];
|
||||||
|
this.cw = source["cw"];
|
||||||
|
this.phone = source["phone"];
|
||||||
|
this.data = source["data"];
|
||||||
|
this.total = source["total"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class BandMode {
|
export class BandMode {
|
||||||
band: string;
|
band: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
@@ -3196,6 +3712,7 @@ export namespace qso {
|
|||||||
my_arrl_sect?: string;
|
my_arrl_sect?: string;
|
||||||
my_vucc_grids?: string;
|
my_vucc_grids?: string;
|
||||||
extras?: Record<string, string>;
|
extras?: Record<string, string>;
|
||||||
|
award_refs?: string;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
created_at: any;
|
created_at: any;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
@@ -3333,6 +3850,7 @@ export namespace qso {
|
|||||||
this.my_arrl_sect = source["my_arrl_sect"];
|
this.my_arrl_sect = source["my_arrl_sect"];
|
||||||
this.my_vucc_grids = source["my_vucc_grids"];
|
this.my_vucc_grids = source["my_vucc_grids"];
|
||||||
this.extras = source["extras"];
|
this.extras = source["extras"];
|
||||||
|
this.award_refs = source["award_refs"];
|
||||||
this.created_at = this.convertValues(source["created_at"], null);
|
this.created_at = this.convertValues(source["created_at"], null);
|
||||||
this.updated_at = this.convertValues(source["updated_at"], null);
|
this.updated_at = this.convertValues(source["updated_at"], null);
|
||||||
}
|
}
|
||||||
@@ -3393,6 +3911,36 @@ export namespace qso {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class SlotStats {
|
||||||
|
slots_worked: number;
|
||||||
|
slots_confirmed: number;
|
||||||
|
dxcc_worked: number;
|
||||||
|
dxcc_confirmed: number;
|
||||||
|
ph_worked: number;
|
||||||
|
ph_confirmed: number;
|
||||||
|
cw_worked: number;
|
||||||
|
cw_confirmed: number;
|
||||||
|
dig_worked: number;
|
||||||
|
dig_confirmed: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SlotStats(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.slots_worked = source["slots_worked"];
|
||||||
|
this.slots_confirmed = source["slots_confirmed"];
|
||||||
|
this.dxcc_worked = source["dxcc_worked"];
|
||||||
|
this.dxcc_confirmed = source["dxcc_confirmed"];
|
||||||
|
this.ph_worked = source["ph_worked"];
|
||||||
|
this.ph_confirmed = source["ph_confirmed"];
|
||||||
|
this.cw_worked = source["cw_worked"];
|
||||||
|
this.cw_confirmed = source["cw_confirmed"];
|
||||||
|
this.dig_worked = source["dig_worked"];
|
||||||
|
this.dig_confirmed = source["dig_confirmed"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class Stats {
|
export class Stats {
|
||||||
total: number;
|
total: number;
|
||||||
unique_calls: number;
|
unique_calls: number;
|
||||||
@@ -3406,12 +3954,17 @@ export namespace qso {
|
|||||||
confirmed_any: number;
|
confirmed_any: number;
|
||||||
by_mode: Bucket[];
|
by_mode: Bucket[];
|
||||||
by_band: Bucket[];
|
by_band: Bucket[];
|
||||||
|
by_band_category: BandCategory[];
|
||||||
by_operator: Bucket[];
|
by_operator: Bucket[];
|
||||||
by_station: Bucket[];
|
by_station: Bucket[];
|
||||||
by_continent: Bucket[];
|
by_continent: Bucket[];
|
||||||
top_entities: Bucket[];
|
top_entities: Bucket[];
|
||||||
by_year: Bucket[];
|
by_year: Bucket[];
|
||||||
by_month: Bucket[];
|
by_month: Bucket[];
|
||||||
|
by_hour: Bucket[];
|
||||||
|
by_day7: Bucket[];
|
||||||
|
by_day30: Bucket[];
|
||||||
|
by_month12: Bucket[];
|
||||||
window_start: string;
|
window_start: string;
|
||||||
window_end: string;
|
window_end: string;
|
||||||
window_hours: number;
|
window_hours: number;
|
||||||
@@ -3445,12 +3998,17 @@ export namespace qso {
|
|||||||
this.confirmed_any = source["confirmed_any"];
|
this.confirmed_any = source["confirmed_any"];
|
||||||
this.by_mode = this.convertValues(source["by_mode"], Bucket);
|
this.by_mode = this.convertValues(source["by_mode"], Bucket);
|
||||||
this.by_band = this.convertValues(source["by_band"], Bucket);
|
this.by_band = this.convertValues(source["by_band"], Bucket);
|
||||||
|
this.by_band_category = this.convertValues(source["by_band_category"], BandCategory);
|
||||||
this.by_operator = this.convertValues(source["by_operator"], Bucket);
|
this.by_operator = this.convertValues(source["by_operator"], Bucket);
|
||||||
this.by_station = this.convertValues(source["by_station"], Bucket);
|
this.by_station = this.convertValues(source["by_station"], Bucket);
|
||||||
this.by_continent = this.convertValues(source["by_continent"], Bucket);
|
this.by_continent = this.convertValues(source["by_continent"], Bucket);
|
||||||
this.top_entities = this.convertValues(source["top_entities"], Bucket);
|
this.top_entities = this.convertValues(source["top_entities"], Bucket);
|
||||||
this.by_year = this.convertValues(source["by_year"], Bucket);
|
this.by_year = this.convertValues(source["by_year"], Bucket);
|
||||||
this.by_month = this.convertValues(source["by_month"], Bucket);
|
this.by_month = this.convertValues(source["by_month"], Bucket);
|
||||||
|
this.by_hour = this.convertValues(source["by_hour"], Bucket);
|
||||||
|
this.by_day7 = this.convertValues(source["by_day7"], Bucket);
|
||||||
|
this.by_day30 = this.convertValues(source["by_day30"], Bucket);
|
||||||
|
this.by_month12 = this.convertValues(source["by_month12"], Bucket);
|
||||||
this.window_start = source["window_start"];
|
this.window_start = source["window_start"];
|
||||||
this.window_end = source["window_end"];
|
this.window_end = source["window_end"];
|
||||||
this.window_hours = source["window_hours"];
|
this.window_hours = source["window_hours"];
|
||||||
@@ -3614,6 +4172,53 @@ export namespace solar {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace spe {
|
||||||
|
|
||||||
|
export class Status {
|
||||||
|
connected: boolean;
|
||||||
|
last_error?: string;
|
||||||
|
model?: string;
|
||||||
|
operate: boolean;
|
||||||
|
tx: boolean;
|
||||||
|
input?: string;
|
||||||
|
band?: string;
|
||||||
|
power_level?: string;
|
||||||
|
output_w: number;
|
||||||
|
swr_atu: number;
|
||||||
|
swr_ant: number;
|
||||||
|
volt_pa: number;
|
||||||
|
curr_pa: number;
|
||||||
|
temp_c: number;
|
||||||
|
warnings?: string;
|
||||||
|
alarms?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Status(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.connected = source["connected"];
|
||||||
|
this.last_error = source["last_error"];
|
||||||
|
this.model = source["model"];
|
||||||
|
this.operate = source["operate"];
|
||||||
|
this.tx = source["tx"];
|
||||||
|
this.input = source["input"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.power_level = source["power_level"];
|
||||||
|
this.output_w = source["output_w"];
|
||||||
|
this.swr_atu = source["swr_atu"];
|
||||||
|
this.swr_ant = source["swr_ant"];
|
||||||
|
this.volt_pa = source["volt_pa"];
|
||||||
|
this.curr_pa = source["curr_pa"];
|
||||||
|
this.temp_c = source["temp_c"];
|
||||||
|
this.warnings = source["warnings"];
|
||||||
|
this.alarms = source["alarms"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace udp {
|
export namespace udp {
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
|
|||||||
+308
-30
@@ -16,6 +16,7 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -66,15 +67,17 @@ type Def struct {
|
|||||||
|
|
||||||
// --- Type & matching ---
|
// --- Type & matching ---
|
||||||
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
||||||
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
||||||
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
|
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
|
||||||
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
|
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
|
||||||
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
|
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
|
||||||
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
|
||||||
Multi bool `json:"multi,omitempty"` // a QSO may count for several references
|
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
|
||||||
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
|
// point in one. A QSO ALWAYS yields every reference its field holds — an n-fer
|
||||||
|
// POTA activation ("US-6544,US-0680"), a VUCC contact on a grid line. The old
|
||||||
|
// `Multi` flag was read by nothing; its checkbox changed nothing.
|
||||||
|
|
||||||
// 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
|
||||||
@@ -94,11 +97,45 @@ type Def struct {
|
|||||||
// --- Confirmation ---
|
// --- Confirmation ---
|
||||||
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
||||||
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
||||||
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
|
// NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but
|
||||||
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
|
// nothing reads them: no ADIF export has ever written CREDIT_GRANTED. Their
|
||||||
|
// controls have been removed from the editor — a checkbox that quietly does
|
||||||
|
// nothing is worse than no checkbox, because it is trusted. Wire these up (in
|
||||||
|
// internal/adif) before showing them again.
|
||||||
|
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
|
||||||
|
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
|
||||||
|
|
||||||
Total int `json:"total"` // known denominator (0 = unknown / derive from list)
|
Total int `json:"total"` // known denominator (0 = unknown / derive from list)
|
||||||
Builtin bool `json:"builtin"` // shipped default (informational)
|
Builtin bool `json:"builtin"` // shipped default (informational)
|
||||||
|
|
||||||
|
// --- Catalog updates ---
|
||||||
|
// Version is the revision of a SHIPPED award. Bump it in the catalog JSON when
|
||||||
|
// you fix a definition (a better OR chain, a corrected reference list) and want
|
||||||
|
// that fix to reach operators who already run the award: on startup, a catalog
|
||||||
|
// award whose Version is higher than the stored one REPLACES it, definition and
|
||||||
|
// references. Awards created by the operator have no version and are never touched.
|
||||||
|
Version int `json:"version,omitempty"`
|
||||||
|
// UserEdited marks an award the operator has changed. A catalog update then
|
||||||
|
// SKIPS it: their work outranks ours. Set the moment the award (or its reference
|
||||||
|
// list) is saved to something other than what the catalog ships.
|
||||||
|
UserEdited bool `json:"user_edited,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SameContent reports whether two definitions describe the same award — ignoring
|
||||||
|
// the bookkeeping fields (version, the user-edited flag, the derived builtin bit),
|
||||||
|
// which say where a definition came from, not what it does. Used to decide whether
|
||||||
|
// a save actually changed anything.
|
||||||
|
func (d Def) SameContent(o Def) bool {
|
||||||
|
a, b := d, o
|
||||||
|
a.Version, b.Version = 0, 0
|
||||||
|
a.UserEdited, b.UserEdited = false, false
|
||||||
|
a.Builtin, b.Builtin = false, false
|
||||||
|
ja, err1 := json.Marshal(a)
|
||||||
|
jb, err2 := json.Marshal(b)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return string(ja) == string(jb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrRule is one additional search OR'd with the award's primary matching rule.
|
// OrRule is one additional search OR'd with the award's primary matching rule.
|
||||||
@@ -200,6 +237,14 @@ func Catalog() []CatalogEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// An award OpsLog SHIPS is built-in, by definition. Derive it here instead of
|
||||||
|
// trusting the flag in the file: you drop in an award you exported, its JSON
|
||||||
|
// says builtin:false (you wrote it, it wasn't built-in then), and it would
|
||||||
|
// quietly miss every future catalog correction. Making the author remember to
|
||||||
|
// flip a flag is exactly the kind of step nobody can guess.
|
||||||
|
for i := range out {
|
||||||
|
out[i].Def.Builtin = true
|
||||||
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
|
sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -273,7 +318,7 @@ func Migrate(defs []Def) ([]Def, bool) {
|
|||||||
func Fields() []string {
|
func Fields() []string {
|
||||||
return []string{
|
return []string{
|
||||||
"dxcc", "cqz", "ituz", "prefix", "callsign",
|
"dxcc", "cqz", "ituz", "prefix", "callsign",
|
||||||
"state", "cont", "country", "grid", "grid4",
|
"state", "us_county", "cont", "country", "grid", "grid4",
|
||||||
"iota", "sota_ref", "pota_ref", "wwff",
|
"iota", "sota_ref", "pota_ref", "wwff",
|
||||||
"name", "qth", "address", "comment", "note",
|
"name", "qth", "address", "comment", "note",
|
||||||
}
|
}
|
||||||
@@ -598,6 +643,63 @@ func InScope(d Def, q *qso.QSO) bool { return inScope(&d, q) }
|
|||||||
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
|
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
|
||||||
func EmissionOf(mode string) string { return emissionOf(mode) }
|
func EmissionOf(mode string) string { return emissionOf(mode) }
|
||||||
|
|
||||||
|
// USCountyKey normalises a QSO's state + cnty into the canonical "STATE,COUNTY"
|
||||||
|
// match code used by the US Counties (USA-CA) award, so the reference list (in
|
||||||
|
// that same form) matches whatever shape the logbook holds. It is the SINGLE
|
||||||
|
// source of truth: cmd/cntygen builds the reference codes by calling it, so the
|
||||||
|
// two sides can never drift.
|
||||||
|
//
|
||||||
|
// Real logs are a mess — "MA,MIDDLESEX", bare "Middlesex" with the state in its
|
||||||
|
// own column, mixed case, county-type suffixes, the odd "0", plus the FIPS list
|
||||||
|
// abbreviating "Saint"→"St." and hyphenating "Matanuska-Susitna". The rules:
|
||||||
|
// - if cnty already carries "ST,County", split on the first comma; else take
|
||||||
|
// the state from the STATE column;
|
||||||
|
// - upper-case; drop periods and apostrophes; hyphens→space; strip a trailing
|
||||||
|
// County/Parish/Borough/Census Area/Municipality; fold Saint(e)→St(e);
|
||||||
|
// collapse whitespace;
|
||||||
|
// - require a 2-letter state and a non-empty county, else no match ("").
|
||||||
|
func USCountyKey(state, cnty string) string {
|
||||||
|
s := strings.TrimSpace(cnty)
|
||||||
|
if s == "" || s == "0" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var st, co string
|
||||||
|
if i := strings.IndexByte(s, ','); i >= 0 {
|
||||||
|
st, co = strings.TrimSpace(s[:i]), strings.TrimSpace(s[i+1:])
|
||||||
|
} else {
|
||||||
|
st, co = strings.TrimSpace(state), s
|
||||||
|
}
|
||||||
|
co = strings.ToUpper(co)
|
||||||
|
co = strings.ReplaceAll(co, ".", "")
|
||||||
|
co = strings.ReplaceAll(co, "'", "")
|
||||||
|
co = strings.ReplaceAll(co, "-", " ")
|
||||||
|
for _, suf := range []string{" COUNTY", " PARISH", " BOROUGH", " CENSUS AREA", " MUNICIPALITY"} {
|
||||||
|
if strings.HasSuffix(co, suf) {
|
||||||
|
co = strings.TrimSuffix(co, suf)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(co, "SAINTE "):
|
||||||
|
co = "STE " + co[len("SAINTE "):]
|
||||||
|
case strings.HasPrefix(co, "SAINT "):
|
||||||
|
co = "ST " + co[len("SAINT "):]
|
||||||
|
}
|
||||||
|
// Drop ALL internal spaces last: FIPS writes DeKalb/DuPage/LaSalle as one
|
||||||
|
// word, logs often split them ("De Kalb"). Removing spaces on both sides
|
||||||
|
// folds those together and can't collide two real counties in one state.
|
||||||
|
co = strings.ReplaceAll(co, " ", "")
|
||||||
|
st = strings.ToUpper(st)
|
||||||
|
if len(st) != 2 || co == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Separator is "/", NOT ",": the QSOFIELDS matcher splits a field value on
|
||||||
|
// commas/semicolons (n-fer POTA "US-1,US-2"), which would shatter "AL,AUTAUGA"
|
||||||
|
// into two non-matching tokens. The stored ADIF cnty keeps its comma; only
|
||||||
|
// this internal match key uses "/".
|
||||||
|
return st + "/" + co
|
||||||
|
}
|
||||||
|
|
||||||
// labelRef fills a worked reference's name/group from the reference list (or the
|
// labelRef fills a worked reference's name/group from the reference list (or the
|
||||||
// name resolver as a fallback).
|
// name resolver as a fallback).
|
||||||
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
|
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
|
||||||
@@ -678,25 +780,149 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
|
|||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Explain: why did (or didn't) this QSO count for this award? ──────────────
|
||||||
|
//
|
||||||
|
// An award that silently matches nothing is the hardest kind of bug to see: the
|
||||||
|
// UI shows an empty column and the operator has no way to tell whether the QSO is
|
||||||
|
// out of scope, the field is empty, the rule looked in the wrong place, or the
|
||||||
|
// reference simply isn't on the list. Explain replays the matcher on ONE QSO and
|
||||||
|
// reports every step it took.
|
||||||
|
|
||||||
|
// Rejected is a candidate a rule produced that did not survive.
|
||||||
|
type Rejected struct {
|
||||||
|
Candidate string `json:"candidate"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step is one matching rule as it actually ran.
|
||||||
|
type Step struct {
|
||||||
|
Rule string `json:"rule"` // "primary", "OR 1", …
|
||||||
|
Field string `json:"field"` // the QSO field it scanned
|
||||||
|
MatchBy string `json:"match_by,omitempty"` // code | description | pattern
|
||||||
|
Exact bool `json:"exact,omitempty"` // whole field IS the reference
|
||||||
|
Pattern string `json:"pattern,omitempty"` // the rule's regex, if any
|
||||||
|
FieldValue string `json:"field_value,omitempty"` // what the field actually held
|
||||||
|
Candidates []string `json:"candidates,omitempty"` // what the rule produced, before validation
|
||||||
|
Kept []string `json:"kept,omitempty"` // what survived the reference list
|
||||||
|
Rejected []Rejected `json:"rejected,omitempty"` // and what didn't, with the reason
|
||||||
|
Skipped bool `json:"skipped,omitempty"` // never ran: an earlier rule already matched
|
||||||
|
Error string `json:"error,omitempty"` // e.g. a bad regex, which SKIPS the rule
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explanation is the full account of one QSO against one award.
|
||||||
|
type Explanation struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
InScope bool `json:"in_scope"`
|
||||||
|
ScopeError string `json:"scope_error,omitempty"` // why the QSO is out of scope
|
||||||
|
Predefined bool `json:"predefined"` // matches are validated against a reference list
|
||||||
|
RefCount int `json:"ref_count"` // size of that list
|
||||||
|
Steps []Step `json:"steps"`
|
||||||
|
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
|
||||||
|
Result []string `json:"result"` // what the QSO finally counts for
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explain runs the matcher on a single QSO and reports what it did. It goes
|
||||||
|
// through the SAME code path as Compute — not a re-implementation — so what it
|
||||||
|
// shows is what actually happens.
|
||||||
|
func Explain(d Def, metas []RefMeta, q *qso.QSO) Explanation {
|
||||||
|
ex := Explanation{Code: d.Code, Steps: []Step{}, Result: []string{}}
|
||||||
|
rl := NewRefList(metas)
|
||||||
|
ex.Predefined = len(metas) > 0 && !d.Dynamic
|
||||||
|
ex.RefCount = len(metas)
|
||||||
|
|
||||||
|
var why string
|
||||||
|
if !inScopeWhy(&d, q, &why) {
|
||||||
|
ex.ScopeError = why
|
||||||
|
return ex // out of scope: no rule ever runs, and saying so IS the answer
|
||||||
|
}
|
||||||
|
ex.InScope = true
|
||||||
|
|
||||||
|
var re *regexp.Regexp
|
||||||
|
if p := strings.TrimSpace(d.Pattern); p != "" {
|
||||||
|
c, err := compileAwardRE(p)
|
||||||
|
if err != nil {
|
||||||
|
ex.Steps = append(ex.Steps, Step{Rule: "primary", Field: d.Field, Pattern: d.Pattern,
|
||||||
|
Error: "bad regex: " + err.Error()})
|
||||||
|
return ex
|
||||||
|
}
|
||||||
|
re = c
|
||||||
|
}
|
||||||
|
candidatesTrace(&d, re, q, rl, len(metas) > 0, &ex)
|
||||||
|
return ex
|
||||||
|
}
|
||||||
|
|
||||||
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
|
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
|
||||||
|
return candidatesTrace(d, re, q, rl, hasList, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidatesTrace is the matcher. ex is optional: when non-nil (only Explain
|
||||||
|
// passes it) each rule records what it scanned, what it produced and what was
|
||||||
|
// rejected. The tracing MUST live inside the real matcher rather than in a
|
||||||
|
// parallel "explain" implementation — a trace that can drift from the code it
|
||||||
|
// describes is worse than no trace, because it is believed.
|
||||||
|
func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool, ex *Explanation) []string {
|
||||||
predefined := hasList && !d.Dynamic
|
predefined := hasList && !d.Dynamic
|
||||||
|
|
||||||
|
// run executes one rule and, when tracing, records it.
|
||||||
|
run := func(label, field, matchBy, pattern string, rex *regexp.Regexp, exact bool, leading, trailing, prefix string) []string {
|
||||||
|
raw := searchOne(field, matchBy, rex, exact, leading, trailing, prefix, q, rl, predefined)
|
||||||
|
kept := keepRefs(predefined, rl, raw)
|
||||||
|
if ex != nil {
|
||||||
|
s := Step{Rule: label, Field: field, MatchBy: matchBy, Exact: exact, Pattern: pattern,
|
||||||
|
FieldValue: strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)),
|
||||||
|
Candidates: raw, Kept: kept}
|
||||||
|
keptSet := map[string]struct{}{}
|
||||||
|
for _, k := range kept {
|
||||||
|
keptSet[k] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, c := range raw {
|
||||||
|
n := normalizeRef(c)
|
||||||
|
if _, ok := keptSet[n]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.Rejected = append(s.Rejected, rejection(predefined, rl, n))
|
||||||
|
}
|
||||||
|
ex.Steps = append(ex.Steps, s)
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
// Primary search first; the OR rules are ordered FALLBACKS — try the next
|
// Primary search first; the OR rules are ordered FALLBACKS — try the next
|
||||||
// only while nothing has matched yet, and stop at the first that yields a
|
// only while nothing has matched yet, and stop at the first that yields a
|
||||||
// reference (short-circuit). So a province already found by NAME isn't also
|
// reference (short-circuit). So a province already found by NAME isn't also
|
||||||
// re-derived, possibly differently, from a later city-regex rule.
|
// re-derived, possibly differently, from a later city-regex rule.
|
||||||
found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)
|
//
|
||||||
for i := 0; len(found) == 0 && i < len(d.OrRules); i++ {
|
// The short-circuit tests what a rule REALLY yielded — the references that
|
||||||
|
// survive the predefined list — not its raw candidates. A rule can always
|
||||||
|
// produce a raw candidate and still find nothing: "the whole ADDRESS field is
|
||||||
|
// the code" hands back "SERIATE (BG) 24068 ITALY", which is not a province.
|
||||||
|
// Testing the raw candidate would call that a hit, skip every fallback, and
|
||||||
|
// only then drop it as unlisted — leaving the QSO unmatched even though the
|
||||||
|
// next rule ("find the code inside the QTH") would have found BG.
|
||||||
|
found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "")
|
||||||
|
for i := range d.OrRules {
|
||||||
r := &d.OrRules[i]
|
r := &d.OrRules[i]
|
||||||
|
label := fmt.Sprintf("OR %d", i+1)
|
||||||
|
if len(found) > 0 {
|
||||||
|
if ex != nil {
|
||||||
|
ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Exact: r.ExactMatch,
|
||||||
|
Pattern: r.Pattern, Skipped: true})
|
||||||
|
}
|
||||||
|
continue // an earlier rule already matched — fallbacks short-circuit
|
||||||
|
}
|
||||||
var rre *regexp.Regexp
|
var rre *regexp.Regexp
|
||||||
if p := strings.TrimSpace(r.Pattern); p != "" {
|
if p := strings.TrimSpace(r.Pattern); p != "" {
|
||||||
c, err := compileAwardRE(p)
|
c, err := compileAwardRE(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ex != nil {
|
||||||
|
ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Pattern: r.Pattern,
|
||||||
|
Error: "bad regex: " + err.Error()})
|
||||||
|
}
|
||||||
continue // skip a rule with a bad regex rather than failing the award
|
continue // skip a rule with a bad regex rather than failing the award
|
||||||
}
|
}
|
||||||
rre = c
|
rre = c
|
||||||
}
|
}
|
||||||
found = searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)
|
found = run(label, r.Field, r.MatchBy, r.Pattern, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge operator-assigned references (manual override, ManualRefsKey). Lets
|
// Merge operator-assigned references (manual override, ManualRefsKey). Lets
|
||||||
@@ -706,18 +932,55 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
|||||||
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
|
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
|
||||||
// awards panel and the per-QSO refs editor — honours overrides too. For a
|
// awards panel and the per-QSO refs editor — honours overrides too. For a
|
||||||
// predefined award the ref is still validated against the list below.
|
// predefined award the ref is still validated against the list below.
|
||||||
for _, c := range manualRefs(q, d.Code) {
|
manual := keepRefs(predefined, rl, manualRefs(q, d.Code))
|
||||||
found = append(found, normalizeRef(c))
|
if ex != nil {
|
||||||
|
ex.Manual = manual
|
||||||
}
|
}
|
||||||
|
found = append(found, manual...)
|
||||||
|
out := dedupe(found)
|
||||||
|
if ex != nil {
|
||||||
|
ex.Result = out
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rejection explains why a candidate the operator can SEE in the trace did not
|
||||||
|
// become a reference. "Nothing matched" is the least useful thing a matcher can
|
||||||
|
// say; every one of this week's award bugs was a rejection with a plain reason
|
||||||
|
// that nothing was printing.
|
||||||
|
func rejection(predefined bool, rl refList, code string) Rejected {
|
||||||
|
switch {
|
||||||
|
case code == "":
|
||||||
|
return Rejected{Candidate: code, Reason: "empty"}
|
||||||
|
case !predefined:
|
||||||
|
return Rejected{Candidate: code, Reason: "duplicate"}
|
||||||
|
}
|
||||||
|
m, ok := rl.byCode[code]
|
||||||
|
if !ok {
|
||||||
|
return Rejected{Candidate: code, Reason: "not in the award's reference list"}
|
||||||
|
}
|
||||||
|
if !m.Valid {
|
||||||
|
return Rejected{Candidate: code, Reason: "listed but disabled"}
|
||||||
|
}
|
||||||
|
return Rejected{Candidate: code, Reason: "duplicate"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepRefs reduces a rule's raw candidates to the references that actually count.
|
||||||
|
// For a predefined award that means the codes on the list, enabled. The
|
||||||
|
// award-level DXCCFilter already scopes which QSOs are considered (see inScope),
|
||||||
|
// so we do NOT additionally require the QSO's entity to match the reference's own
|
||||||
|
// DXCC — that wrongly excluded e.g. WAS Alaska (state AK is DXCC entity 6, not
|
||||||
|
// 291). Per-reference DXCC stays metadata for the picker.
|
||||||
|
func keepRefs(predefined bool, rl refList, found []string) []string {
|
||||||
if !predefined {
|
if !predefined {
|
||||||
return dedupe(found)
|
out := make([]string, 0, len(found))
|
||||||
|
for _, c := range found {
|
||||||
|
if c = normalizeRef(c); c != "" {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dedupe(out)
|
||||||
}
|
}
|
||||||
// Enforce the predefined list: keep only listed, valid references. The
|
|
||||||
// award-level DXCCFilter already scopes which QSOs are considered (see
|
|
||||||
// inScope), so we do NOT additionally require the QSO's entity to match the
|
|
||||||
// reference's own DXCC — that wrongly excluded e.g. WAS Alaska (state AK is
|
|
||||||
// DXCC entity 6, not 291). Per-reference DXCC stays metadata for the picker.
|
|
||||||
var out []string
|
var out []string
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, c := range found {
|
for _, c := range found {
|
||||||
@@ -858,24 +1121,37 @@ func natLess(a, b string) bool {
|
|||||||
|
|
||||||
// inScope reports whether a QSO falls within an award's scope (DXCC entity,
|
// inScope reports whether a QSO falls within an award's scope (DXCC entity,
|
||||||
// bands, modes, emission category, validity dates).
|
// bands, modes, emission category, validity dates).
|
||||||
func inScope(d *Def, q *qso.QSO) bool {
|
func inScope(d *Def, q *qso.QSO) bool { return inScopeWhy(d, q, nil) }
|
||||||
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
|
|
||||||
|
// inScopeWhy is inScope with an optional explanation. why is filled ONLY when the
|
||||||
|
// QSO is out of scope AND a caller asked for the reason (Explain does; Compute,
|
||||||
|
// which runs this for every QSO × every award, passes nil and pays nothing).
|
||||||
|
// Keeping both behind one function is the point: a scope check that disagrees with
|
||||||
|
// the scope check it explains would be worse than no explanation at all.
|
||||||
|
func inScopeWhy(d *Def, q *qso.QSO, why *string) bool {
|
||||||
|
fail := func(format string, args ...any) bool {
|
||||||
|
if why != nil {
|
||||||
|
*why = fmt.Sprintf(format, args...)
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
|
||||||
|
return fail("DXCC %d is not in the award's filter %v", q.DXCC, d.DXCCFilter)
|
||||||
|
}
|
||||||
if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) {
|
if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) {
|
||||||
return false
|
return fail("band %q is not among the valid bands %v", q.Band, d.ValidBands)
|
||||||
}
|
}
|
||||||
if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) {
|
if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) {
|
||||||
return false
|
return fail("mode %q is not among the valid modes %v", q.Mode, d.ValidModes)
|
||||||
}
|
}
|
||||||
if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) {
|
if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) {
|
||||||
return false
|
return fail("mode %q is %s emission; the award accepts %v", q.Mode, emissionOf(q.Mode), d.Emission)
|
||||||
}
|
}
|
||||||
if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom {
|
if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom {
|
||||||
return false
|
return fail("QSO of %s predates the award's start date (%s)", q.QSODate.Format("2006-01-02"), d.ValidFrom)
|
||||||
}
|
}
|
||||||
if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo {
|
if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo {
|
||||||
return false
|
return fail("QSO of %s is after the award's end date (%s)", q.QSODate.Format("2006-01-02"), d.ValidTo)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -926,6 +1202,8 @@ func fieldRaw(field string, q *qso.QSO) string {
|
|||||||
return q.Callsign
|
return q.Callsign
|
||||||
case "state":
|
case "state":
|
||||||
return q.State
|
return q.State
|
||||||
|
case "us_county":
|
||||||
|
return USCountyKey(q.State, q.County)
|
||||||
case "cont":
|
case "cont":
|
||||||
return q.Continent
|
return q.Continent
|
||||||
case "country":
|
case "country":
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package award
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
)
|
)
|
||||||
@@ -287,6 +289,160 @@ func TestComputeGrid4VUCC(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FFMA ships a fixed list of the 488 grids of the contiguous 48 states, taken
|
||||||
|
// from arrl.org/ffma. The count is the award's own checksum — if this test ever
|
||||||
|
// fails on the count, the catalog file is wrong, not the test.
|
||||||
|
func TestCatalogFFMA(t *testing.T) {
|
||||||
|
raw, ok := CatalogRefs("FFMA")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("FFMA has no reference list in the embedded catalog")
|
||||||
|
}
|
||||||
|
var refs []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
DXCC int `json:"dxcc"`
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &refs); err != nil {
|
||||||
|
t.Fatalf("FFMA references: %v", err)
|
||||||
|
}
|
||||||
|
if len(refs) != 488 {
|
||||||
|
t.Fatalf("FFMA has %d grids, want exactly 488", len(refs))
|
||||||
|
}
|
||||||
|
|
||||||
|
var def Def
|
||||||
|
metas := make([]RefMeta, 0, len(refs))
|
||||||
|
for _, r := range refs {
|
||||||
|
// Rule 4(c): a grid may be activated from Canadian or Mexican soil, or from
|
||||||
|
// water. Pinning a reference to a DXCC entity would reject those contacts.
|
||||||
|
if r.DXCC != 0 {
|
||||||
|
t.Fatalf("FFMA grid %s is pinned to DXCC %d — rule 4(c) allows working it from outside the US", r.Code, r.DXCC)
|
||||||
|
}
|
||||||
|
metas = append(metas, RefMeta{Code: r.Code, Valid: r.Valid})
|
||||||
|
}
|
||||||
|
for _, d := range Defaults() {
|
||||||
|
if d.Code == "FFMA" {
|
||||||
|
def = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if def.Total != 488 || def.Field != "grid4" {
|
||||||
|
t.Fatalf("FFMA def: total=%d field=%q, want 488 / grid4", def.Total, def.Field)
|
||||||
|
}
|
||||||
|
|
||||||
|
d1983 := time.Date(1990, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "K5ABC", Band: "6m", Grid: "EM00AA", QSODate: d1983, LOTWRcvd: "Y"}, // counts
|
||||||
|
{Callsign: "VE3XYZ", Band: "6m", Grid: "FN25AA", QSODate: d1983, LOTWRcvd: "Y"}, // a VE3 in an FFMA grid: counts (4c)
|
||||||
|
{Callsign: "W1LINE", Band: "6m", VUCCGrids: "FN31,FN32", QSODate: d1983, QSLRcvd: "Y"}, // grid line: 2 grids (4d)
|
||||||
|
{Callsign: "G4XXX", Band: "6m", Grid: "IO91AA", QSODate: d1983, LOTWRcvd: "Y"}, // not an FFMA grid
|
||||||
|
{Callsign: "K5ABC", Band: "2m", Grid: "EM10AA", QSODate: d1983, LOTWRcvd: "Y"}, // wrong band
|
||||||
|
{Callsign: "K5OLD", Band: "6m", Grid: "EM20AA", QSODate: time.Date(1982, 6, 1, 0, 0, 0, 0, time.UTC), LOTWRcvd: "Y"}, // before 1983 (rule 2)
|
||||||
|
}
|
||||||
|
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"FFMA": metas}, nil)[0]
|
||||||
|
var got []string
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Worked {
|
||||||
|
got = append(got, rf.Ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(got)
|
||||||
|
want := []string{"EM00", "FN25", "FN31", "FN32"}
|
||||||
|
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||||
|
t.Fatalf("FFMA worked = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if r.Worked != len(want) || r.Total != 488 {
|
||||||
|
t.Errorf("FFMA worked=%d total=%d, want %d / 488", r.Worked, r.Total, len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAIP: the primary rule takes the WHOLE address as the province code (exact
|
||||||
|
// match), which can never be one — but it does produce a raw candidate. The OR
|
||||||
|
// fallback (find the code inside the QTH, "SERIATE (BG)") is the rule that works,
|
||||||
|
// and it must still run: a rule that yields nothing VALID is not a hit.
|
||||||
|
func TestComputeOrFallbackAfterUnlistedPrimary(t *testing.T) {
|
||||||
|
def := Def{
|
||||||
|
Code: "WAIP", Name: "Worked All Italian Provinces", Valid: true,
|
||||||
|
Type: TypeReference, Field: "address", MatchBy: "code", ExactMatch: true,
|
||||||
|
OrRules: []OrRule{{Field: "qth", MatchBy: "code"}}, // not exact → search inside the field
|
||||||
|
Confirm: []string{"lotw", "qsl"},
|
||||||
|
}
|
||||||
|
metas := []RefMeta{
|
||||||
|
{Code: "BG", Name: "Bergamo", Valid: true},
|
||||||
|
{Code: "MI", Name: "Milano", Valid: true},
|
||||||
|
}
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy", LOTWRcvd: "Y"},
|
||||||
|
}
|
||||||
|
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"WAIP": metas}, nil)[0]
|
||||||
|
if r.Worked != 1 {
|
||||||
|
t.Fatalf("WAIP worked = %d, want 1 (BG, from the QTH fallback)", r.Worked)
|
||||||
|
}
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Ref == "BG" && rf.Worked {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("BG not worked; refs = %v", refCodes(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explain must account for the WAIP case exactly: the primary rule produces a
|
||||||
|
// candidate that is NOT a province, says so, and the QTH fallback then finds BG.
|
||||||
|
func TestExplainAccountsForEveryRule(t *testing.T) {
|
||||||
|
def := Def{
|
||||||
|
Code: "WAIP", Valid: true, Type: TypeReference,
|
||||||
|
Field: "address", MatchBy: "code", ExactMatch: true,
|
||||||
|
OrRules: []OrRule{{Field: "qth", MatchBy: "code"}},
|
||||||
|
Confirm: []string{"lotw"},
|
||||||
|
}
|
||||||
|
metas := []RefMeta{{Code: "BG", Name: "Bergamo", Valid: true}}
|
||||||
|
q := &qso.QSO{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy"}
|
||||||
|
|
||||||
|
ex := Explain(def, metas, q)
|
||||||
|
if !ex.InScope || len(ex.Steps) != 2 {
|
||||||
|
t.Fatalf("in scope=%v, %d steps, want in scope with 2 (primary + OR 1): %+v", ex.InScope, len(ex.Steps), ex)
|
||||||
|
}
|
||||||
|
// The primary rule LOOKS like it found something. Saying only "no match" here is
|
||||||
|
// what cost hours: the trace has to show the candidate and why it lost.
|
||||||
|
p := ex.Steps[0]
|
||||||
|
if len(p.Candidates) == 0 {
|
||||||
|
t.Error("primary produced no candidate; the whole point is that it produces a bogus one")
|
||||||
|
}
|
||||||
|
if len(p.Kept) != 0 || len(p.Rejected) == 0 {
|
||||||
|
t.Fatalf("primary kept=%v rejected=%v, want nothing kept and a stated reason", p.Kept, p.Rejected)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Rejected[0].Reason, "not in the award's reference list") {
|
||||||
|
t.Errorf("reason = %q, want it to name the real cause", p.Rejected[0].Reason)
|
||||||
|
}
|
||||||
|
if or1 := ex.Steps[1]; or1.Skipped || len(or1.Kept) != 1 || or1.Kept[0] != "BG" {
|
||||||
|
t.Errorf("OR 1 = %+v, want it to run and find BG", or1)
|
||||||
|
}
|
||||||
|
if len(ex.Result) != 1 || ex.Result[0] != "BG" {
|
||||||
|
t.Errorf("result = %v, want [BG]", ex.Result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A trace that disagrees with the matcher is worse than none: it is believed.
|
||||||
|
if got := MatchQSO(def, metas, q); strings.Join(got, ",") != strings.Join(ex.Result, ",") {
|
||||||
|
t.Errorf("Explain says %v but MatchQSO says %v — the trace does not describe the code", ex.Result, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExplainOutOfScopeSaysWhy(t *testing.T) {
|
||||||
|
def := Def{Code: "FFMA", Valid: true, Field: "grid4", MatchBy: "code", ExactMatch: true,
|
||||||
|
ValidBands: []string{"6m"}, ValidFrom: "1983-01-01", Confirm: []string{"lotw"}}
|
||||||
|
q := &qso.QSO{Callsign: "K1ABC", Band: "2m", Grid: "EM00AA",
|
||||||
|
QSODate: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)}
|
||||||
|
|
||||||
|
ex := Explain(def, []RefMeta{{Code: "EM00", Valid: true}}, q)
|
||||||
|
if ex.InScope {
|
||||||
|
t.Fatal("a 2 m QSO is not in scope for a 6 m-only award")
|
||||||
|
}
|
||||||
|
if !strings.Contains(ex.ScopeError, "2m") {
|
||||||
|
t.Errorf("scope error = %q, want it to name the band that fails", ex.ScopeError)
|
||||||
|
}
|
||||||
|
if len(ex.Steps) != 0 {
|
||||||
|
t.Errorf("%d steps ran on an out-of-scope QSO; none should", len(ex.Steps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func refCodes(r Result) []string {
|
func refCodes(r Result) []string {
|
||||||
out := make([]string, 0, len(r.Refs))
|
out := make([]string, 0, len(r.Refs))
|
||||||
for _, rf := range r.Refs {
|
for _, rf := range r.Refs {
|
||||||
@@ -508,3 +664,23 @@ func TestCatalogSurvivesOneBadFile(t *testing.T) {
|
|||||||
// Catalog() skips unparseable files rather than returning nil, so the others
|
// Catalog() skips unparseable files rather than returning nil, so the others
|
||||||
// still load. (Verified structurally: the loader `continue`s on error.)
|
// still load. (Verified structurally: the loader `continue`s on error.)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anything OpsLog SHIPS is built-in, whatever the file says.
|
||||||
|
//
|
||||||
|
// You create an award, export it (its JSON records builtin:false — it wasn't
|
||||||
|
// built-in when you wrote it), then drop that same file into the catalog to ship
|
||||||
|
// it. If we trusted the flag, the award would go out to everyone marked "not
|
||||||
|
// built-in" and would then silently miss every future catalog correction. Making
|
||||||
|
// the author remember to flip a flag first is a step nobody can guess — so the
|
||||||
|
// catalog derives it instead.
|
||||||
|
func TestCatalogForcesBuiltin(t *testing.T) {
|
||||||
|
for _, e := range Catalog() {
|
||||||
|
if !e.Def.Builtin {
|
||||||
|
t.Errorf("%s: shipped in the catalog but Builtin=false — it would miss catalog corrections", e.Def.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The realistic case: a user-authored award whose JSON says builtin:false.
|
||||||
|
if len(Catalog()) == 0 {
|
||||||
|
t.Fatal("empty catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package award
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A synthetic log with the shape a real one has: a spread of bands, modes,
|
||||||
|
// entities, and the free-text fields the harder awards actually scan.
|
||||||
|
func benchLog(n int) []qso.QSO {
|
||||||
|
bands := []string{"160m", "80m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"}
|
||||||
|
modes := []string{"SSB", "CW", "FT8", "FT4", "RTTY"}
|
||||||
|
entities := []int{291, 248, 227, 230, 339, 5, 108, 110, 6, 281}
|
||||||
|
towns := []string{"SERIATE (BG)", "MILANO (MI)", "ROMA (RM)", "TORINO (TO)", "NAPOLI (NA)"}
|
||||||
|
out := make([]qso.QSO, n)
|
||||||
|
base := time.Date(2015, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
for i := range out {
|
||||||
|
e := entities[i%len(entities)]
|
||||||
|
out[i] = qso.QSO{
|
||||||
|
ID: int64(i + 1),
|
||||||
|
Callsign: fmt.Sprintf("I%dABC", i%10),
|
||||||
|
Band: bands[i%len(bands)],
|
||||||
|
Mode: modes[i%len(modes)],
|
||||||
|
DXCC: &e,
|
||||||
|
QSODate: base.Add(time.Duration(i) * time.Hour),
|
||||||
|
QTH: towns[i%len(towns)],
|
||||||
|
Address: "Via Roma 12, 24068 " + towns[i%len(towns)] + " Italy",
|
||||||
|
Grid: "JN45AA",
|
||||||
|
State: "CA",
|
||||||
|
LOTWRcvd: "Y",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAIP is the worst realistic shape: a predefined list, an exact-match primary
|
||||||
|
// that always produces a doomed candidate, then a fallback chain that tokenises a
|
||||||
|
// free-text field. If anything is slow, it is this.
|
||||||
|
func benchWAIP() (Def, []RefMeta) {
|
||||||
|
d := Def{
|
||||||
|
Code: "WAIP", Valid: true, Type: TypeReference,
|
||||||
|
Field: "address", MatchBy: "code", ExactMatch: true,
|
||||||
|
OrRules: []OrRule{
|
||||||
|
{Field: "qth", MatchBy: "description"},
|
||||||
|
{Field: "qth", MatchBy: "code"},
|
||||||
|
{Field: "address", MatchBy: "code"},
|
||||||
|
},
|
||||||
|
DXCCFilter: []int{248},
|
||||||
|
Confirm: []string{"lotw", "qsl"},
|
||||||
|
}
|
||||||
|
codes := []string{"AG", "AL", "AN", "AO", "AP", "AQ", "AR", "AT", "AV", "BA", "BG", "BI", "BL", "BN", "BO", "BR", "BS", "BT", "BZ", "CA", "CB", "CE", "CH", "CL", "CN", "CO", "CR", "CS", "CT", "CZ", "EN", "FC", "FE", "FG", "FI", "FM", "FR", "GE", "GO", "GR", "IM", "IS", "KR", "LC", "LE", "LI", "LO", "LT", "LU", "MB", "MC", "ME", "MI", "MN", "MO", "MS", "MT", "NA", "NO", "NU", "OR", "PA", "PC", "PD", "PE", "PG", "PI", "PN", "PO", "PR", "PT", "PU", "PV", "PZ", "RA", "RC", "RE", "RG", "RI", "RM", "RN", "RO", "SA", "SI", "SO", "SP", "SR", "SS", "SU", "SV", "TA", "TE", "TN", "TO", "TP", "TR", "TS", "TV", "UD", "VA", "VB", "VC", "VE", "VI", "VR", "VT", "VV"}
|
||||||
|
metas := make([]RefMeta, 0, len(codes))
|
||||||
|
for _, c := range codes {
|
||||||
|
metas = append(metas, RefMeta{Code: c, Name: "Province " + c, Valid: true})
|
||||||
|
}
|
||||||
|
return d, metas
|
||||||
|
}
|
||||||
|
|
||||||
|
// One award, whole log — what GetAward(code) does when the Awards panel opens.
|
||||||
|
func BenchmarkComputeOneAward(b *testing.B) {
|
||||||
|
d, metas := benchWAIP()
|
||||||
|
for _, n := range []int{10_000, 50_000, 100_000} {
|
||||||
|
log := benchLog(n)
|
||||||
|
b.Run(fmt.Sprintf("WAIP/%dqso", n), func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
Compute([]Def{d}, log, map[string][]RefMeta{"WAIP": metas}, nil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every shipped award at once — GetAwards(), the heaviest thing the app can ask.
|
||||||
|
func BenchmarkComputeWholeCatalog(b *testing.B) {
|
||||||
|
defs := Defaults()
|
||||||
|
metas := map[string][]RefMeta{}
|
||||||
|
wd, wm := benchWAIP()
|
||||||
|
defs = append(defs, wd)
|
||||||
|
metas["WAIP"] = wm
|
||||||
|
for _, c := range Catalog() {
|
||||||
|
if raw, ok := CatalogRefs(c.Def.Code); ok {
|
||||||
|
var refs []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &refs) == nil {
|
||||||
|
m := make([]RefMeta, 0, len(refs))
|
||||||
|
for _, r := range refs {
|
||||||
|
m = append(m, RefMeta{Code: r.Code, Name: r.Name, Valid: r.Valid})
|
||||||
|
}
|
||||||
|
metas[c.Def.Code] = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range []int{10_000, 100_000} {
|
||||||
|
log := benchLog(n)
|
||||||
|
b.Run(fmt.Sprintf("%dawards/%dqso", len(defs), n), func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
Compute(defs, log, metas, nil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,27 @@
|
|||||||
"name": "Départements Français Métropolitains",
|
"name": "Départements Français Métropolitains",
|
||||||
"valid": true,
|
"valid": true,
|
||||||
"protected": true,
|
"protected": true,
|
||||||
|
"ref_display": "name",
|
||||||
"type": "QSOFIELDS",
|
"type": "QSOFIELDS",
|
||||||
"field": "note",
|
"field": "note",
|
||||||
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
|
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "code",
|
||||||
|
"pattern": "\\b(\\d{2})\\d{3}\\b",
|
||||||
|
"prefix": "D"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "code",
|
||||||
|
"pattern": "\\b(\\d{2})\\d{3}\\b",
|
||||||
|
"prefix": "D"
|
||||||
|
}
|
||||||
|
],
|
||||||
"dxcc_filter": [
|
"dxcc_filter": [
|
||||||
227
|
227,
|
||||||
|
214
|
||||||
],
|
],
|
||||||
"confirm": [
|
"confirm": [
|
||||||
"lotw",
|
"lotw",
|
||||||
@@ -18,6 +34,777 @@
|
|||||||
"lotw"
|
"lotw"
|
||||||
],
|
],
|
||||||
"total": 96,
|
"total": 96,
|
||||||
"builtin": true
|
"builtin": true,
|
||||||
}
|
"version": 2
|
||||||
}
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "D01",
|
||||||
|
"name": "Ain",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D02",
|
||||||
|
"name": "Aisne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D03",
|
||||||
|
"name": "Allier",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D04",
|
||||||
|
"name": "Alpes-de-Haute-Provence",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D05",
|
||||||
|
"name": "Hautes-Alpes",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D06",
|
||||||
|
"name": "Alpes-Maritimes",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D07",
|
||||||
|
"name": "Ardèche",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D08",
|
||||||
|
"name": "Ardennes",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D09",
|
||||||
|
"name": "Ariège",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D10",
|
||||||
|
"name": "Aube",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D11",
|
||||||
|
"name": "Aude",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D12",
|
||||||
|
"name": "Aveyron",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D13",
|
||||||
|
"name": "Bouches-du-Rhône",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D14",
|
||||||
|
"name": "Calvados",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D15",
|
||||||
|
"name": "Cantal",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D16",
|
||||||
|
"name": "Charente",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D17",
|
||||||
|
"name": "Charente-Maritime",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D18",
|
||||||
|
"name": "Cher",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D19",
|
||||||
|
"name": "Corrèze",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D21",
|
||||||
|
"name": "Côte-d'Or",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D22",
|
||||||
|
"name": "Côtes-d'Armor",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D23",
|
||||||
|
"name": "Creuse",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D24",
|
||||||
|
"name": "Dordogne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D25",
|
||||||
|
"name": "Doubs",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D26",
|
||||||
|
"name": "Drôme",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D27",
|
||||||
|
"name": "Eure",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D28",
|
||||||
|
"name": "Eure-et-Loir",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D29",
|
||||||
|
"name": "Finistère",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D2A",
|
||||||
|
"name": "Corse-du-Sud",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D2B",
|
||||||
|
"name": "Haute-Corse",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D30",
|
||||||
|
"name": "Gard",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D31",
|
||||||
|
"name": "Haute-Garonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D32",
|
||||||
|
"name": "Gers",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D33",
|
||||||
|
"name": "Gironde",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D34",
|
||||||
|
"name": "Hérault",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D35",
|
||||||
|
"name": "Ille-et-Vilaine",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D36",
|
||||||
|
"name": "Indre",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D37",
|
||||||
|
"name": "Indre-et-Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D38",
|
||||||
|
"name": "Isère",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D39",
|
||||||
|
"name": "Jura",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D40",
|
||||||
|
"name": "Landes",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D41",
|
||||||
|
"name": "Loir-et-Cher",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D42",
|
||||||
|
"name": "Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D43",
|
||||||
|
"name": "Haute-Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D44",
|
||||||
|
"name": "Loire-Atlantique",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D45",
|
||||||
|
"name": "Loiret",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D46",
|
||||||
|
"name": "Lot",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D47",
|
||||||
|
"name": "Lot-et-Garonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D48",
|
||||||
|
"name": "Lozère",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D49",
|
||||||
|
"name": "Maine-et-Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D50",
|
||||||
|
"name": "Manche",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D51",
|
||||||
|
"name": "Marne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D52",
|
||||||
|
"name": "Haute-Marne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D53",
|
||||||
|
"name": "Mayenne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D54",
|
||||||
|
"name": "Meurthe-et-Moselle",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D55",
|
||||||
|
"name": "Meuse",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D56",
|
||||||
|
"name": "Morbihan",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D57",
|
||||||
|
"name": "Moselle",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D58",
|
||||||
|
"name": "Nièvre",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D59",
|
||||||
|
"name": "Nord",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D60",
|
||||||
|
"name": "Oise",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D61",
|
||||||
|
"name": "Orne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D62",
|
||||||
|
"name": "Pas-de-Calais",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D63",
|
||||||
|
"name": "Puy-de-Dôme",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D64",
|
||||||
|
"name": "Pyrénées-Atlantiques",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D65",
|
||||||
|
"name": "Hautes-Pyrénées",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D66",
|
||||||
|
"name": "Pyrénées-Orientales",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D67",
|
||||||
|
"name": "Bas-Rhin",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D68",
|
||||||
|
"name": "Haut-Rhin",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D69",
|
||||||
|
"name": "Rhône",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D70",
|
||||||
|
"name": "Haute-Saône",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D71",
|
||||||
|
"name": "Saône-et-Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D72",
|
||||||
|
"name": "Sarthe",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D73",
|
||||||
|
"name": "Savoie",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D74",
|
||||||
|
"name": "Haute-Savoie",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D75",
|
||||||
|
"name": "Paris",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D76",
|
||||||
|
"name": "Seine-Maritime",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D77",
|
||||||
|
"name": "Seine-et-Marne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D78",
|
||||||
|
"name": "Yvelines",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D79",
|
||||||
|
"name": "Deux-Sèvres",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D80",
|
||||||
|
"name": "Somme",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D81",
|
||||||
|
"name": "Tarn",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D82",
|
||||||
|
"name": "Tarn-et-Garonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D83",
|
||||||
|
"name": "Var",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D84",
|
||||||
|
"name": "Vaucluse",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D85",
|
||||||
|
"name": "Vendée",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D86",
|
||||||
|
"name": "Vienne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D87",
|
||||||
|
"name": "Haute-Vienne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D88",
|
||||||
|
"name": "Vosges",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D89",
|
||||||
|
"name": "Yonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D90",
|
||||||
|
"name": "Territoire de Belfort",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D91",
|
||||||
|
"name": "Essonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D92",
|
||||||
|
"name": "Hauts-de-Seine",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D93",
|
||||||
|
"name": "Seine-Saint-Denis",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D94",
|
||||||
|
"name": "Val-de-Marne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D95",
|
||||||
|
"name": "Val-d'Oise",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T17:00:44Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "RAC",
|
||||||
|
"name": "RAC Canadian Provinces",
|
||||||
|
"description": "RAC Canadian Provinces",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"url": "https://www.rac.ca/canadaward/",
|
||||||
|
"valid_from": "1977-01-07",
|
||||||
|
"ref_display": "name",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "state",
|
||||||
|
"match_by": "code",
|
||||||
|
"pattern": "",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "description"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dxcc_filter": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"emission": [
|
||||||
|
"CW",
|
||||||
|
"PHONE",
|
||||||
|
"DIGITAL"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "AB",
|
||||||
|
"name": "Alberta",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BC",
|
||||||
|
"name": "British Columbia",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MB",
|
||||||
|
"name": "Manitoba",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NB",
|
||||||
|
"name": "New Brunswick",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NL",
|
||||||
|
"name": "Newfoundland and Labrador",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NS",
|
||||||
|
"name": "Nova Scotia",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NT",
|
||||||
|
"name": "Northwest Territories",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NU",
|
||||||
|
"name": "Nunavut",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ON",
|
||||||
|
"name": "Ontario",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PE",
|
||||||
|
"name": "Prince Edward Island",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QC",
|
||||||
|
"name": "Quebec",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SK",
|
||||||
|
"name": "Saskatchewan",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "YT",
|
||||||
|
"name": "Yukon",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "USA-CA",
|
||||||
|
"name": "USA-CA (US Counties)",
|
||||||
|
"description": "CQ United States of America Counties Award. Matches the QSO's county, tolerating LoTW \"ST,County\" and bare county-name shapes. Independent cities of Virginia/Nevada and DC are excluded per the award rules.",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "us_county",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"ref_display": "name",
|
||||||
|
"url": "https://cq-amateur-radio.com/cq_awards/cq_usa_ca_awards/cq_usa_ca_awards.html",
|
||||||
|
"dxcc_filter": [
|
||||||
|
291,
|
||||||
|
110,
|
||||||
|
6
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 3077,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T17:00:44Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "VUCC",
|
||||||
|
"name": "VHF/UHF Century Club",
|
||||||
|
"description": "VHF/UHF Century Club",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"url": "https://www.arrl.org/files/file/Awards%20Application%20Forms/VUCCRULE1a.pdf",
|
||||||
|
"valid_from": "1970-01-01",
|
||||||
|
"valid_to": "9999-01-30",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "grid4",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"valid_bands": [
|
||||||
|
"6m",
|
||||||
|
"4m",
|
||||||
|
"2m",
|
||||||
|
"70cm",
|
||||||
|
"23cm",
|
||||||
|
"13cm",
|
||||||
|
"1.25m"
|
||||||
|
],
|
||||||
|
"emission": [
|
||||||
|
"CW",
|
||||||
|
"PHONE",
|
||||||
|
"DIGITAL"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"references": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,929 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T21:57:23Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAIP",
|
||||||
|
"name": "ARI Worked All Italian Provinces",
|
||||||
|
"valid": true,
|
||||||
|
"url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html",
|
||||||
|
"ref_url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html",
|
||||||
|
"type": "REFERENCE",
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "code"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "description"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dxcc_filter": [
|
||||||
|
248
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": false
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "AG",
|
||||||
|
"name": "Agrigento",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AL",
|
||||||
|
"name": "Alessandria",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AN",
|
||||||
|
"name": "Ancona",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AO",
|
||||||
|
"name": "Aosta",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Val d'Aosta",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AP",
|
||||||
|
"name": "Ascoli Piceno",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AQ",
|
||||||
|
"name": "L'Aquila",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Abruzzo",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AR",
|
||||||
|
"name": "Arezzo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AT",
|
||||||
|
"name": "Asti",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AV",
|
||||||
|
"name": "Avellino",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BA",
|
||||||
|
"name": "Bari",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BG",
|
||||||
|
"name": "Bergamo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BI",
|
||||||
|
"name": "Biella",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BL",
|
||||||
|
"name": "Belluno",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BN",
|
||||||
|
"name": "Benevento",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BO",
|
||||||
|
"name": "Bologna",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BR",
|
||||||
|
"name": "Brindisi",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BS",
|
||||||
|
"name": "Brescia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BT",
|
||||||
|
"name": "Barletta-Andria-Trani",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BZ",
|
||||||
|
"name": "Bolzano/Bozen",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Trentino-AltoAdige/Südtirol",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CA",
|
||||||
|
"name": "Cagliari",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CB",
|
||||||
|
"name": "Campobasso",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Molise",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CE",
|
||||||
|
"name": "Caserta",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CH",
|
||||||
|
"name": "Chieti",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Abruzzo",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CI",
|
||||||
|
"name": "Carbonia-Iglesias",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CL",
|
||||||
|
"name": "Caltanissetta",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CN",
|
||||||
|
"name": "Cuneo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CO",
|
||||||
|
"name": "Como",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CR",
|
||||||
|
"name": "Cremona",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CS",
|
||||||
|
"name": "Cosenza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CT",
|
||||||
|
"name": "Catania",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CZ",
|
||||||
|
"name": "Catanzaro",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "EN",
|
||||||
|
"name": "Enna",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FC",
|
||||||
|
"name": "Forlì-Cesena",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FE",
|
||||||
|
"name": "Ferrara",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FG",
|
||||||
|
"name": "Foggia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FI",
|
||||||
|
"name": "Firenze",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FM",
|
||||||
|
"name": "Fermo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FR",
|
||||||
|
"name": "Frosinone",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GE",
|
||||||
|
"name": "Genova",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Liguria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GO",
|
||||||
|
"name": "Gorizia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Friuli-Venezia Giulia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GR",
|
||||||
|
"name": "Grosseto",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "IM",
|
||||||
|
"name": "Imperia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Liguria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "IS",
|
||||||
|
"name": "Isernia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Molise",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "KR",
|
||||||
|
"name": "Crotone",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LC",
|
||||||
|
"name": "Lecco",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LE",
|
||||||
|
"name": "Lecce",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LI",
|
||||||
|
"name": "Livorno",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LO",
|
||||||
|
"name": "Lodi",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LT",
|
||||||
|
"name": "Latina",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LU",
|
||||||
|
"name": "Lucca",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MB",
|
||||||
|
"name": "Monza e Brianza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MC",
|
||||||
|
"name": "Macerata",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ME",
|
||||||
|
"name": "Messina",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MI",
|
||||||
|
"name": "Milano",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MN",
|
||||||
|
"name": "Mantova",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MO",
|
||||||
|
"name": "Modena",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MS",
|
||||||
|
"name": "Massa-Carrara",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MT",
|
||||||
|
"name": "Matera",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Basilicata",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NA",
|
||||||
|
"name": "Napoli",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NO",
|
||||||
|
"name": "Novara",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NU",
|
||||||
|
"name": "Nuoro",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "OG",
|
||||||
|
"name": "Ogliastra",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "OR",
|
||||||
|
"name": "Oristano",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "OT",
|
||||||
|
"name": "Olbia-Tempio",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PA",
|
||||||
|
"name": "Palermo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PC",
|
||||||
|
"name": "Piacenza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PD",
|
||||||
|
"name": "Padova",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PE",
|
||||||
|
"name": "Pescara",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Abruzzo",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PG",
|
||||||
|
"name": "Perugia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Umbria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PI",
|
||||||
|
"name": "Pisa",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PN",
|
||||||
|
"name": "Pordenone",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Friuli-Venezia Giulia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PO",
|
||||||
|
"name": "Prato",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PR",
|
||||||
|
"name": "Parma",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PT",
|
||||||
|
"name": "Pistoia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PU",
|
||||||
|
"name": "Pesaro-Urbino",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PV",
|
||||||
|
"name": "Pavia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PZ",
|
||||||
|
"name": "Potenza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Basilicata",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RA",
|
||||||
|
"name": "Ravenna",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RC",
|
||||||
|
"name": "Reggio Calabria",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RE",
|
||||||
|
"name": "Reggio Emilia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RG",
|
||||||
|
"name": "Ragusa",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RI",
|
||||||
|
"name": "Rieti",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RM",
|
||||||
|
"name": "Roma",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RN",
|
||||||
|
"name": "Rimini",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RO",
|
||||||
|
"name": "Rovigo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SA",
|
||||||
|
"name": "Salerno",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SI",
|
||||||
|
"name": "Siena",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SO",
|
||||||
|
"name": "Sondrio",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SP",
|
||||||
|
"name": "La Spezia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Liguria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SR",
|
||||||
|
"name": "Siracusa",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SS",
|
||||||
|
"name": "Sassari",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SV",
|
||||||
|
"name": "Savona",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Liguria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TA",
|
||||||
|
"name": "Taranto",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TE",
|
||||||
|
"name": "Teramo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Abruzzo",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TN",
|
||||||
|
"name": "Trento",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Trentino-AltoAdige/Südtirol",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TO",
|
||||||
|
"name": "Torino",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TP",
|
||||||
|
"name": "Trapani",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TR",
|
||||||
|
"name": "Terni",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Umbria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TS",
|
||||||
|
"name": "Trieste",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Friuli-Venezia Giulia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TV",
|
||||||
|
"name": "Treviso",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "UD",
|
||||||
|
"name": "Udine",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Friuli-Venezia Giulia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VA",
|
||||||
|
"name": "Varese",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VB",
|
||||||
|
"name": "Verbano-Cusio-Ossola",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VC",
|
||||||
|
"name": "Vercelli",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VE",
|
||||||
|
"name": "Venezia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VI",
|
||||||
|
"name": "Vicenza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VR",
|
||||||
|
"name": "Verona",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VS",
|
||||||
|
"name": "Medio Campidano",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VT",
|
||||||
|
"name": "Viterbo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VV",
|
||||||
|
"name": "Vibo Valentia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package award
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestUSCountyKey(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
state, cnty, want string
|
||||||
|
}{
|
||||||
|
{"MA", "MA,MIDDLESEX", "MA/MIDDLESEX"}, // LoTW "ST,County" shape
|
||||||
|
{"NJ", "Middlesex", "NJ/MIDDLESEX"}, // bare name + state column
|
||||||
|
{"TX", "Montgomery", "TX/MONTGOMERY"}, // title case
|
||||||
|
{"FL", "Saint Lucie", "FL/STLUCIE"}, // Saint→St, space dropped
|
||||||
|
{"FL", "St. Lucie", "FL/STLUCIE"}, // FIPS abbreviation, period dropped
|
||||||
|
{"AK", "Matanuska-Susitna", "AK/MATANUSKASUSITNA"}, // hyphen→space→dropped
|
||||||
|
{"IL", "De Kalb", "IL/DEKALB"}, // split name
|
||||||
|
{"IL", "DeKalb County", "IL/DEKALB"}, // suffix + one word
|
||||||
|
{"LA", "Acadia Parish", "LA/ACADIA"}, // parish suffix
|
||||||
|
{"", "Honolulu", ""}, // no state → no match
|
||||||
|
{"HI", "0", ""}, // garbage
|
||||||
|
{"HI", "", ""}, // empty
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := USCountyKey(c.state, c.cnty); got != c.want {
|
||||||
|
t.Errorf("USCountyKey(%q,%q) = %q, want %q", c.state, c.cnty, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
// - WAC → continent code ("EU", "NA", …)
|
// - WAC → continent code ("EU", "NA", …)
|
||||||
// - WAS → ADIF STATE code ("AL", …)
|
// - WAS → ADIF STATE code ("AL", …)
|
||||||
// - DDFM → "D06" (the award pattern captures the leading D)
|
// - DDFM → "D06" (the award pattern captures the leading D)
|
||||||
|
// - USA-CA → canonical "STATE/COUNTY" key (see award.USCountyKey)
|
||||||
func BuiltinRefs(code string) ([]Ref, bool) {
|
func BuiltinRefs(code string) ([]Ref, bool) {
|
||||||
switch code {
|
switch code {
|
||||||
case "DXCC":
|
case "DXCC":
|
||||||
@@ -29,6 +30,8 @@ func BuiltinRefs(code string) ([]Ref, bool) {
|
|||||||
return usStates().Refs, true
|
return usStates().Refs, true
|
||||||
case "DDFM":
|
case "DDFM":
|
||||||
return frenchDepartments(), true
|
return frenchDepartments(), true
|
||||||
|
case "USA-CA":
|
||||||
|
return usCounties(), true
|
||||||
}
|
}
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -304,6 +304,13 @@ type FlexTXState struct {
|
|||||||
ANFLevel int `json:"anf_level"`
|
ANFLevel int `json:"anf_level"`
|
||||||
WNB bool `json:"wnb"`
|
WNB bool `json:"wnb"`
|
||||||
WNBLevel int `json:"wnb_level"`
|
WNBLevel int `json:"wnb_level"`
|
||||||
|
// RIT/XIT — offsets applied to the active slice's RX / TX frequency without
|
||||||
|
// moving the slice. The offset survives the switch being turned off, so
|
||||||
|
// re-enabling restores it, exactly like the radio's own knob.
|
||||||
|
RIT bool `json:"rit"`
|
||||||
|
RITFreq int `json:"rit_freq"`
|
||||||
|
XIT bool `json:"xit"`
|
||||||
|
XITFreq int `json:"xit_freq"`
|
||||||
// CW / mode-specific controls.
|
// CW / mode-specific controls.
|
||||||
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
|
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
|
||||||
CWSpeed int `json:"cw_speed"`
|
CWSpeed int `json:"cw_speed"`
|
||||||
@@ -345,6 +352,7 @@ type FlexController interface {
|
|||||||
SetRFPower(int) error
|
SetRFPower(int) error
|
||||||
SetTunePower(int) error
|
SetTunePower(int) error
|
||||||
SetTune(bool) error
|
SetTune(bool) error
|
||||||
|
SetTXInhibit(bool) error
|
||||||
SetVOX(bool) error
|
SetVOX(bool) error
|
||||||
SetVOXLevel(int) error
|
SetVOXLevel(int) error
|
||||||
SetVOXDelay(int) error
|
SetVOXDelay(int) error
|
||||||
@@ -378,10 +386,22 @@ type FlexController interface {
|
|||||||
SetAPFLevel(int) error
|
SetAPFLevel(int) error
|
||||||
SetWNB(bool) error
|
SetWNB(bool) error
|
||||||
SetWNBLevel(int) error
|
SetWNBLevel(int) error
|
||||||
|
SetRIT(bool) error
|
||||||
|
SetRITFreq(int) error
|
||||||
|
SetXIT(bool) error
|
||||||
|
SetXITFreq(int) error
|
||||||
// CW keyer + mode-specific controls.
|
// CW keyer + mode-specific controls.
|
||||||
SetCWSpeed(int) error
|
SetCWSpeed(int) error
|
||||||
SetCWPitch(int) error
|
SetCWPitch(int) error
|
||||||
SetCWBreakInDelay(int) error
|
SetCWBreakInDelay(int) error
|
||||||
|
// CWX keyer — buffered CW keying via SmartSDR's CWX subsystem, so a Flex needs
|
||||||
|
// no WinKeyer / SmartCAT. SendCW queues text (the radio buffers and keys it);
|
||||||
|
// StopCW clears the buffer, aborting the send. BackspaceCW removes the last n
|
||||||
|
// not-yet-keyed characters from the buffer (un-send while sending — for
|
||||||
|
// type-ahead corrections).
|
||||||
|
SendCW(string) error
|
||||||
|
StopCW() error
|
||||||
|
BackspaceCW(int) error
|
||||||
SetCWSidetone(bool) error
|
SetCWSidetone(bool) error
|
||||||
SetSidetoneLevel(int) error
|
SetSidetoneLevel(int) error
|
||||||
SetCWFilter(int) error
|
SetCWFilter(int) error
|
||||||
|
|||||||
+96
-3
@@ -90,8 +90,12 @@ type flexSlice struct {
|
|||||||
apfLevel int
|
apfLevel int
|
||||||
wnb bool // wideband noise blanker
|
wnb bool // wideband noise blanker
|
||||||
wnbLevel int
|
wnbLevel int
|
||||||
filterLo int // slice filter low cut (Hz)
|
rit bool // receive incremental tuning enabled
|
||||||
filterHi int // slice filter high cut (Hz)
|
ritFreq int // RIT offset in Hz (negative = down)
|
||||||
|
xit bool // transmit incremental tuning enabled
|
||||||
|
xitFreq int // XIT offset in Hz
|
||||||
|
filterLo int // slice filter low cut (Hz)
|
||||||
|
filterHi int // slice filter high cut (Hz)
|
||||||
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
|
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
|
||||||
txAnt string // selected TX antenna
|
txAnt string // selected TX antenna
|
||||||
antList []string // antennas valid for this slice (RX side)
|
antList []string // antennas valid for this slice (RX side)
|
||||||
@@ -105,6 +109,7 @@ type flexTX struct {
|
|||||||
tunePower int
|
tunePower int
|
||||||
tune bool
|
tune bool
|
||||||
transmitting bool // interlock state == TRANSMITTING
|
transmitting bool // interlock state == TRANSMITTING
|
||||||
|
inhibit bool // transmit inhibited (e.g. while a motorized antenna moves)
|
||||||
voxEnable bool
|
voxEnable bool
|
||||||
voxLevel int
|
voxLevel int
|
||||||
voxDelay int
|
voxDelay int
|
||||||
@@ -707,7 +712,7 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
for _, id := range newIDs {
|
for _, id := range newIDs {
|
||||||
mi := f.meterMeta[id]
|
mi := f.meterMeta[id]
|
||||||
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s → sub", id, mi.src, mi.name, mi.unit)
|
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s lo=%g hi=%g → sub", id, mi.src, mi.name, mi.unit, mi.lo, mi.hi)
|
||||||
f.subscribeMeter(id)
|
f.subscribeMeter(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -804,6 +809,14 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
s.wnb = val == "1"
|
s.wnb = val == "1"
|
||||||
case "wnb_level":
|
case "wnb_level":
|
||||||
s.wnbLevel = atoiDefault(val, s.wnbLevel)
|
s.wnbLevel = atoiDefault(val, s.wnbLevel)
|
||||||
|
case "rit_on":
|
||||||
|
s.rit = val == "1"
|
||||||
|
case "rit_freq":
|
||||||
|
s.ritFreq = atoiDefault(val, s.ritFreq)
|
||||||
|
case "xit_on":
|
||||||
|
s.xit = val == "1"
|
||||||
|
case "xit_freq":
|
||||||
|
s.xitFreq = atoiDefault(val, s.xitFreq)
|
||||||
case "filter_lo":
|
case "filter_lo":
|
||||||
s.filterLo = atoiDefault(val, s.filterLo)
|
s.filterLo = atoiDefault(val, s.filterLo)
|
||||||
case "filter_hi":
|
case "filter_hi":
|
||||||
@@ -1315,6 +1328,10 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
st.APFLevel = rx.apfLevel
|
st.APFLevel = rx.apfLevel
|
||||||
st.WNB = rx.wnb
|
st.WNB = rx.wnb
|
||||||
st.WNBLevel = rx.wnbLevel
|
st.WNBLevel = rx.wnbLevel
|
||||||
|
st.RIT = rx.rit
|
||||||
|
st.RITFreq = rx.ritFreq
|
||||||
|
st.XIT = rx.xit
|
||||||
|
st.XITFreq = rx.xitFreq
|
||||||
st.FilterLo = rx.filterLo
|
st.FilterLo = rx.filterLo
|
||||||
st.FilterHi = rx.filterHi
|
st.FilterHi = rx.filterHi
|
||||||
st.RXAnt = rx.rxAnt
|
st.RXAnt = rx.rxAnt
|
||||||
@@ -1383,6 +1400,14 @@ func (f *Flex) sendSlice(param string, val any) error {
|
|||||||
rx.rxAnt = fmt.Sprint(val)
|
rx.rxAnt = fmt.Sprint(val)
|
||||||
case "txant":
|
case "txant":
|
||||||
rx.txAnt = fmt.Sprint(val)
|
rx.txAnt = fmt.Sprint(val)
|
||||||
|
case "rit_on":
|
||||||
|
rx.rit = val == "1"
|
||||||
|
case "rit_freq":
|
||||||
|
rx.ritFreq = toInt(val)
|
||||||
|
case "xit_on":
|
||||||
|
rx.xit = val == "1"
|
||||||
|
case "xit_freq":
|
||||||
|
rx.xitFreq = toInt(val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
@@ -1490,6 +1515,26 @@ func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on))
|
|||||||
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
||||||
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
|
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
|
||||||
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
|
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
|
||||||
|
// RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active
|
||||||
|
// slice, without moving the slice itself. SmartSDR keeps the offset even while the
|
||||||
|
// switch is off, so turning RIT back on restores the last offset — same as the
|
||||||
|
// radio's own knob.
|
||||||
|
func (f *Flex) SetRIT(on bool) error { return f.sendSlice("rit_on", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetRITFreq(hz int) error { return f.sendSlice("rit_freq", clampOffset(hz)) }
|
||||||
|
func (f *Flex) SetXIT(on bool) error { return f.sendSlice("xit_on", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetXITFreq(hz int) error { return f.sendSlice("xit_freq", clampOffset(hz)) }
|
||||||
|
|
||||||
|
// clampOffset keeps a RIT/XIT offset inside what SmartSDR accepts (±99 999 Hz).
|
||||||
|
func clampOffset(hz int) int {
|
||||||
|
if hz > 99999 {
|
||||||
|
return 99999
|
||||||
|
}
|
||||||
|
if hz < -99999 {
|
||||||
|
return -99999
|
||||||
|
}
|
||||||
|
return hz
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
|
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
|
||||||
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
|
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
|
||||||
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
|
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
|
||||||
@@ -1536,6 +1581,37 @@ func (f *Flex) SetCWBreakInDelay(ms int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendCW queues text on the radio's CWX keyer. SmartSDR buffers and keys it, so
|
||||||
|
// no WinKeyer / SmartCAT is needed — and because the radio owns the buffer,
|
||||||
|
// characters fed while it's already sending append and key in order (the basis
|
||||||
|
// for type-ahead). Quotes/backslashes are escaped for the "cwx send \"…\"" form.
|
||||||
|
func (f *Flex) SendCW(text string) error {
|
||||||
|
text = strings.TrimRight(text, "\r\n")
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
esc := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(text)
|
||||||
|
f.send(`cwx send "` + esc + `"`)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopCW clears the CWX buffer, aborting whatever is currently being keyed.
|
||||||
|
func (f *Flex) StopCW() error {
|
||||||
|
f.send("cwx clear")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackspaceCW removes the last n not-yet-keyed characters from the CWX buffer —
|
||||||
|
// the "un-send while sending" a serial WinKeyer can't do. Used for type-ahead
|
||||||
|
// corrections (backspacing a mistyped call before the radio has keyed it).
|
||||||
|
func (f *Flex) BackspaceCW(n int) error {
|
||||||
|
if n < 1 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("cwx erase %d", n))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Flex) SetCWSidetone(on bool) error {
|
func (f *Flex) SetCWSidetone(on bool) error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.tx.cwSidetone = on
|
f.tx.cwSidetone = on
|
||||||
@@ -1664,6 +1740,23 @@ func (f *Flex) SetTune(on bool) error {
|
|||||||
return f.txSet(cmd, "tune", func(t *flexTX) { t.tune = on })
|
return f.txSet(cmd, "tune", func(t *flexTX) { t.tune = on })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTXInhibit blocks (on=true) or allows transmission at the radio. Used to keep
|
||||||
|
// the operator from keying while a motorized antenna's elements are moving —
|
||||||
|
// SmartSDR refuses to transmit while inhibit is set, so it holds even against a
|
||||||
|
// footswitch or an external keyer, which a software PTT block could not.
|
||||||
|
//
|
||||||
|
// It also labels the interlock reason "OpsLog" so SmartSDR shows WHY TX is
|
||||||
|
// blocked. The reason is best-effort (a separate interlock object); the inhibit
|
||||||
|
// is what actually blocks TX, so a rig that ignores the reason still stays safe.
|
||||||
|
func (f *Flex) SetTXInhibit(on bool) error {
|
||||||
|
if on {
|
||||||
|
f.send("interlock set reason=OpsLog")
|
||||||
|
} else {
|
||||||
|
f.send("interlock set reason=")
|
||||||
|
}
|
||||||
|
return f.txSet("transmit set inhibit="+boolFlex(on), "inhibit", func(t *flexTX) { t.inhibit = on })
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Flex) SetVOX(on bool) error {
|
func (f *Flex) SetVOX(on bool) error {
|
||||||
return f.txSet("transmit set vox_enable="+boolFlex(on), "vox_enable", func(t *flexTX) { t.voxEnable = on })
|
return f.txSet("transmit set vox_enable="+boolFlex(on), "vox_enable", func(t *flexTX) { t.voxEnable = on })
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-1
@@ -136,6 +136,11 @@ type icomNet struct {
|
|||||||
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
||||||
lastRx atomic.Int64
|
lastRx atomic.Int64
|
||||||
|
|
||||||
|
// dead is set when the rig explicitly tears the session down (control 0x05):
|
||||||
|
// Alive() then returns false immediately so ReadState fails on the next poll and
|
||||||
|
// the manager reconnects cleanly, instead of waiting out the 6 s lastRx timeout.
|
||||||
|
dead atomic.Bool
|
||||||
|
|
||||||
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
|
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
|
||||||
// Torn down alongside the CI-V/control streams in Close.
|
// Torn down alongside the CI-V/control streams in Close.
|
||||||
audio *icomAudio
|
audio *icomAudio
|
||||||
@@ -178,6 +183,9 @@ func (n *icomNet) markRx() { n.lastRx.Store(time.Now().UnixNano()) }
|
|||||||
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
|
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
|
||||||
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
|
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
|
||||||
func (n *icomNet) Alive() bool {
|
func (n *icomNet) Alive() bool {
|
||||||
|
if n.dead.Load() {
|
||||||
|
return false // rig sent an explicit disconnect — reconnect now, don't wait
|
||||||
|
}
|
||||||
last := n.lastRx.Load()
|
last := n.lastRx.Load()
|
||||||
if last == 0 {
|
if last == 0 {
|
||||||
return true // just connected, nothing received yet — give it a chance
|
return true // just connected, nothing received yet — give it a chance
|
||||||
@@ -286,19 +294,34 @@ func (n *icomNet) ctrlPump() {
|
|||||||
switch icnLE.Uint16(buf[4:]) {
|
switch icnLE.Uint16(buf[4:]) {
|
||||||
case 0x07: // ping
|
case 0x07: // ping
|
||||||
_, _ = n.ctrl.Write(icnPingReply(buf[:k], n.cID, n.cRemote))
|
_, _ = n.ctrl.Write(icnPingReply(buf[:k], n.cID, n.cRemote))
|
||||||
|
case 0x00: // idle keepalive from the rig — nothing to do
|
||||||
case 0x01: // retransmit request — resend from the CONTROL sent-buffer
|
case 0x01: // retransmit request — resend from the CONTROL sent-buffer
|
||||||
if k >= 8 {
|
if k >= 8 {
|
||||||
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
||||||
}
|
}
|
||||||
case 0x05: // rig-initiated disconnect — it dropped US
|
case 0x05: // rig-initiated disconnect — it dropped US
|
||||||
|
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
||||||
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
||||||
|
default:
|
||||||
|
// Anything else on the control stream is (almost always) the rig's
|
||||||
|
// reply to our token renewal. Log it: a 0x40-length token packet
|
||||||
|
// carries a result code, and if the rig is REJECTING renewals this is
|
||||||
|
// where the ~2-3 min disconnect originates. The hex makes the cause
|
||||||
|
// visible in the friend's log without a protocol analyzer.
|
||||||
|
if k >= 0x18 {
|
||||||
|
debugLog.Printf("icom net: control reply len=%d head=% X", k, buf[:0x18])
|
||||||
|
} else {
|
||||||
|
debugLog.Printf("icom net: control reply len=%d head=% X", k, buf[:k])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if time.Since(lastIdle) > 100*time.Millisecond {
|
if time.Since(lastIdle) > 100*time.Millisecond {
|
||||||
_, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote))
|
_, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote))
|
||||||
lastIdle = time.Now()
|
lastIdle = time.Now()
|
||||||
}
|
}
|
||||||
if time.Since(lastToken) > 45*time.Second {
|
// Renew well inside the rig's ~2-min token timeout. 30 s (was 45) leaves room
|
||||||
|
// for one lost renewal + its retransmit before the token would lapse.
|
||||||
|
if time.Since(lastToken) > 30*time.Second {
|
||||||
n.renewToken()
|
n.renewToken()
|
||||||
lastToken = time.Now()
|
lastToken = time.Now()
|
||||||
}
|
}
|
||||||
@@ -353,6 +376,7 @@ func (n *icomNet) civPump() {
|
|||||||
n.resend(icnLE.Uint16(buf[6:]))
|
n.resend(icnLE.Uint16(buf[6:]))
|
||||||
}
|
}
|
||||||
case typ == 0x05: // rig-initiated disconnect — it dropped US
|
case typ == 0x05: // rig-initiated disconnect — it dropped US
|
||||||
|
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
||||||
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
|
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
|
||||||
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
|
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
|
||||||
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
|||||||
b.dspMu.Unlock()
|
b.dspMu.Unlock()
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
|
||||||
return RigState{}, err // control link dead → let the Manager reconnect
|
return RigState{}, err // control link dead → let the Manager reconnect
|
||||||
}
|
}
|
||||||
// USB (no liveness signal): the rig briefly stops answering CI-V while it
|
// USB (no liveness signal): the rig briefly stops answering CI-V while it
|
||||||
|
|||||||
+15
-10
@@ -32,6 +32,7 @@ type OmniRig struct {
|
|||||||
omnirig *ole.IDispatch
|
omnirig *ole.IDispatch
|
||||||
rig *ole.IDispatch
|
rig *ole.IDispatch
|
||||||
lastSig string // last logged Split/VFO signature — only log on change
|
lastSig string // last logged Split/VFO signature — only log on change
|
||||||
|
rigType string // OmniRig's RigType string (the .ini title), e.g. "IC-7610"
|
||||||
|
|
||||||
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
|
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
|
||||||
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
|
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
|
||||||
@@ -80,7 +81,8 @@ func (o *OmniRig) Connect() error {
|
|||||||
o.rig = rigVar.ToIDispatch()
|
o.rig = rigVar.ToIDispatch()
|
||||||
|
|
||||||
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
||||||
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, rt.ToString())
|
o.rigType = rt.ToString()
|
||||||
|
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, o.rigType)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -194,17 +196,20 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
|||||||
s.FreqHz, s.RxFreqHz = freqB, freqA
|
s.FreqHz, s.RxFreqHz = freqB, freqA
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Simplex: the operating frequency is OmniRig's generic Freq (the active
|
// Simplex: read VFO A first, fall back to the generic Freq — exactly like
|
||||||
// VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0.
|
// DXHunter/WSJT-X. PM_FREQA rigs (Yaesu, Kenwood) populate FreqA; some
|
||||||
|
// Icoms (IC-9100 etc.) only populate the generic Freq. On the IC-7610
|
||||||
|
// OmniRig's generic Freq reports VFO B (its Main/Sub model confuses the
|
||||||
|
// stock ini), so keying off FreqA gives the operator the VFO they expect.
|
||||||
s.Split = false
|
s.Split = false
|
||||||
s.RxFreqHz = 0
|
s.RxFreqHz = 0
|
||||||
s.FreqHz = freqMain
|
switch {
|
||||||
if s.FreqHz == 0 {
|
case freqA != 0:
|
||||||
if s.Vfo == "B" || s.Vfo == "BB" {
|
s.FreqHz = freqA
|
||||||
s.FreqHz = freqB
|
case freqMain != 0:
|
||||||
} else {
|
s.FreqHz = freqMain
|
||||||
s.FreqHz = freqA
|
default:
|
||||||
}
|
s.FreqHz = freqB
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
|
|||||||
@@ -534,8 +534,14 @@ func (s *session) emitLine(text string, sent bool) {
|
|||||||
// ---------- parsing ----------
|
// ---------- parsing ----------
|
||||||
|
|
||||||
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
|
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
|
||||||
|
//
|
||||||
|
// The spotter→freq separator is (?::\s*|\s+): a colon followed by ANY number of
|
||||||
|
// spaces (including ZERO), or one-or-more spaces with no colon. Some RBN skimmer
|
||||||
|
// nodes glue the frequency straight onto the colon — "DX de DL1HWS-3-#:14024.0 …"
|
||||||
|
// — which the old ":?\s+" (colon then a REQUIRED space) rejected, dropping every
|
||||||
|
// spot from those nodes.
|
||||||
var spotRE = regexp.MustCompile(
|
var spotRE = regexp.MustCompile(
|
||||||
`^\s*DX\s+de\s+([A-Z0-9/#\-]+):?\s+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
|
`^\s*DX\s+de\s+([A-Z0-9/#\-]+)(?::\s*|\s+)(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pacing for the per-server init commands.
|
// Pacing for the per-server init commands.
|
||||||
|
|||||||
@@ -1,389 +0,0 @@
|
|||||||
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
|
|
||||||
// mono PCM samples into decoded text. The pipeline is the classic one — a bank
|
|
||||||
// of Goertzel tone detectors, a pitch LOCK that follows a single tone (so QRM
|
|
||||||
// at other pitches is ignored), an adaptive envelope/threshold on the LOCKED
|
|
||||||
// tone (level-independent, so weak or strong signals both key cleanly), an
|
|
||||||
// adaptive dot-length (WPM) estimate, and a timing state machine that maps
|
|
||||||
// marks/spaces to Morse and then to characters.
|
|
||||||
//
|
|
||||||
// It is deliberately self-contained and dependency-free so it can be unit
|
|
||||||
// tested with synthetic signals. As with every audio CW decoder, weak signals
|
|
||||||
// and very heavy QRM still degrade it; the pitch lock keeps QRM on other tones
|
|
||||||
// out of the decode.
|
|
||||||
package cwdecode
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"sort"
|
|
||||||
"sync/atomic"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
|
|
||||||
type Status struct {
|
|
||||||
WPM int `json:"wpm"`
|
|
||||||
Pitch int `json:"pitch"` // Hz of the locked tone (0 = not locked)
|
|
||||||
Level float64 `json:"level"` // 0..1 input audio level (RMS) for the meter
|
|
||||||
Active bool `json:"active"` // a tone is currently keyed down
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decoder consumes PCM and emits decoded characters via onChar (one or more
|
|
||||||
// characters at a time, including " " for word gaps) and periodic onStatus.
|
|
||||||
type Decoder struct {
|
|
||||||
fs int
|
|
||||||
hop int // samples between updates
|
|
||||||
win int // Goertzel window length
|
|
||||||
freqs []float64
|
|
||||||
coeffs []float64 // precomputed 2*cos(w) per freq
|
|
||||||
|
|
||||||
ring []float64 // last win samples
|
|
||||||
acc int // samples since last hop
|
|
||||||
mags []float64 // per-bin magnitude this hop
|
|
||||||
nbuf []float64 // scratch for the noise percentile
|
|
||||||
|
|
||||||
// Fixed-pitch target (Hz). 0 = auto-search; >0 = lock to the nearest bin and
|
|
||||||
// ignore everything else (e.g. follow the radio's CW pitch). Set live from
|
|
||||||
// another goroutine, so it's atomic.
|
|
||||||
targetHz atomic.Int32
|
|
||||||
|
|
||||||
// Pitch lock.
|
|
||||||
lockIdx int // index of the locked tone bin, -1 = unlocked
|
|
||||||
candIdx int // current argmax candidate while unlocked
|
|
||||||
candHops int // consecutive hops the candidate has been dominant
|
|
||||||
quietHops int // consecutive key-up hops while locked
|
|
||||||
noise float64 // broadband noise estimate (percentile of bins)
|
|
||||||
relockHops int // quiet hops before the lock is released
|
|
||||||
acqSNR float64 // tone/noise ratio to acquire after a few stable hops
|
|
||||||
strongSNR float64 // tone/noise ratio to lock immediately (1 hop)
|
|
||||||
|
|
||||||
// Adaptive keying envelope, on the LOCKED bin's magnitude.
|
|
||||||
peak, floor float64
|
|
||||||
state bool // true = mark (key down)
|
|
||||||
stateHops int
|
|
||||||
dotHops float64 // adaptive dot length, in hops
|
|
||||||
markCount int // marks seen since lock (fast WPM adaptation while small)
|
|
||||||
elem []byte // current "." / "-" run for the in-progress character
|
|
||||||
charEmitted bool
|
|
||||||
wordEmitted bool
|
|
||||||
|
|
||||||
lastPitch float64
|
|
||||||
lastRMS float64
|
|
||||||
|
|
||||||
statusEvery int
|
|
||||||
sinceStatus int
|
|
||||||
|
|
||||||
onChar func(string)
|
|
||||||
onStatus func(Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
var morse = map[string]byte{
|
|
||||||
".-": 'A', "-...": 'B', "-.-.": 'C', "-..": 'D', ".": 'E', "..-.": 'F',
|
|
||||||
"--.": 'G', "....": 'H', "..": 'I', ".---": 'J', "-.-": 'K', ".-..": 'L',
|
|
||||||
"--": 'M', "-.": 'N', "---": 'O', ".--.": 'P', "--.-": 'Q', ".-.": 'R',
|
|
||||||
"...": 'S', "-": 'T', "..-": 'U', "...-": 'V', ".--": 'W', "-..-": 'X',
|
|
||||||
"-.--": 'Y', "--..": 'Z',
|
|
||||||
"-----": '0', ".----": '1', "..---": '2', "...--": '3', "....-": '4',
|
|
||||||
".....": '5', "-....": '6', "--...": '7', "---..": '8', "----.": '9',
|
|
||||||
".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
|
|
||||||
".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
|
|
||||||
}
|
|
||||||
|
|
||||||
// New builds a decoder for the given sample rate. onChar receives decoded text
|
|
||||||
// incrementally; onStatus receives ~10 snapshots/second. Either may be nil.
|
|
||||||
func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
|
|
||||||
if sampleRate <= 0 {
|
|
||||||
sampleRate = 16000
|
|
||||||
}
|
|
||||||
d := &Decoder{
|
|
||||||
fs: sampleRate,
|
|
||||||
hop: sampleRate / 250, // ~4 ms resolution
|
|
||||||
win: sampleRate / 72, // ~14 ms Goertzel window (selective, fairly snappy)
|
|
||||||
dotHops: 15, // ~20 WPM seed
|
|
||||||
acqSNR: 1.9, // ignore noise spikes (looser locked onto noise = garbage)
|
|
||||||
strongSNR: 3.2, // only a genuinely strong tone locks in 1 hop
|
|
||||||
lockIdx: -1,
|
|
||||||
candIdx: -1,
|
|
||||||
statusEvery: 25, // ~10 Hz
|
|
||||||
onChar: onChar,
|
|
||||||
onStatus: onStatus,
|
|
||||||
}
|
|
||||||
if d.hop < 1 {
|
|
||||||
d.hop = 1
|
|
||||||
}
|
|
||||||
d.relockHops = int(0.8 * float64(d.fs) / float64(d.hop)) // release lock after ~0.8 s quiet
|
|
||||||
// Candidate CW tones: 400–1000 Hz every 25 Hz. Deliberately NOT lower: strong
|
|
||||||
// low-frequency noise/hum (pink/red noise rises toward DC) would otherwise win
|
|
||||||
// the argmax and lock the decoder onto ~250 Hz junk instead of the signal.
|
|
||||||
for f := 400.0; f <= 1000.0; f += 25 {
|
|
||||||
d.freqs = append(d.freqs, f)
|
|
||||||
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
|
|
||||||
}
|
|
||||||
d.mags = make([]float64, len(d.freqs))
|
|
||||||
d.nbuf = make([]float64, len(d.freqs))
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetTarget fixes the decode pitch to hz (lock to the nearest bin, ignore other
|
|
||||||
// tones), or returns to auto-search when hz <= 0. Safe to call concurrently.
|
|
||||||
func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) }
|
|
||||||
|
|
||||||
// nearestBin returns the bin index closest to hz.
|
|
||||||
func (d *Decoder) nearestBin(hz float64) int {
|
|
||||||
best, bestD := 0, math.Inf(1)
|
|
||||||
for i, f := range d.freqs {
|
|
||||||
if dd := math.Abs(f - hz); dd < bestD {
|
|
||||||
bestD, best = dd, i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset clears decode state (e.g. when the user re-arms the decoder).
|
|
||||||
func (d *Decoder) Reset() {
|
|
||||||
d.ring = d.ring[:0]
|
|
||||||
d.acc = 0
|
|
||||||
d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
|
|
||||||
d.peak, d.floor = 0, 0
|
|
||||||
d.state = false
|
|
||||||
d.stateHops = 0
|
|
||||||
d.dotHops = 15
|
|
||||||
d.markCount = 0
|
|
||||||
d.elem = d.elem[:0]
|
|
||||||
d.charEmitted, d.wordEmitted = false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process feeds a block of mono samples through the decoder.
|
|
||||||
func (d *Decoder) Process(samples []int16) {
|
|
||||||
for _, s := range samples {
|
|
||||||
d.ring = append(d.ring, float64(s))
|
|
||||||
if len(d.ring) > d.win {
|
|
||||||
d.ring = d.ring[len(d.ring)-d.win:]
|
|
||||||
}
|
|
||||||
d.acc++
|
|
||||||
if d.acc >= d.hop && len(d.ring) >= d.win {
|
|
||||||
d.acc = 0
|
|
||||||
d.analyze()
|
|
||||||
d.step()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// analyze runs the Goertzel bank, estimates the noise floor, and maintains the
|
|
||||||
// pitch lock (which tone the envelope detector then follows).
|
|
||||||
func (d *Decoder) analyze() {
|
|
||||||
n := float64(len(d.ring))
|
|
||||||
var sumSq float64
|
|
||||||
maxIdx, maxMag := 0, -1.0
|
|
||||||
for i, coeff := range d.coeffs {
|
|
||||||
var s1, s2 float64
|
|
||||||
for _, x := range d.ring {
|
|
||||||
s0 := x + coeff*s1 - s2
|
|
||||||
s2 = s1
|
|
||||||
s1 = s0
|
|
||||||
}
|
|
||||||
m := math.Sqrt(math.Max(s1*s1+s2*s2-coeff*s1*s2, 0)) / n
|
|
||||||
d.mags[i] = m
|
|
||||||
if m > maxMag {
|
|
||||||
maxMag = m
|
|
||||||
maxIdx = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, x := range d.ring {
|
|
||||||
sumSq += x * x
|
|
||||||
}
|
|
||||||
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4)
|
|
||||||
|
|
||||||
// Fixed-pitch mode: lock straight to the target bin, skip the auto search.
|
|
||||||
// A narrow filter at the known pitch is exactly how a skimmer avoids QRM.
|
|
||||||
if th := int(d.targetHz.Load()); th > 0 {
|
|
||||||
d.lockIdx = d.nearestBin(float64(th))
|
|
||||||
d.lastPitch = d.freqs[d.lockIdx]
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Noise floor = 40th percentile of the bins (robust to a few strong tones).
|
|
||||||
copy(d.nbuf, d.mags)
|
|
||||||
sort.Float64s(d.nbuf)
|
|
||||||
d.noise = d.nbuf[int(0.4*float64(len(d.nbuf)-1)+0.5)]
|
|
||||||
|
|
||||||
if d.lockIdx < 0 {
|
|
||||||
if maxIdx == d.candIdx {
|
|
||||||
d.candHops++
|
|
||||||
} else {
|
|
||||||
d.candIdx, d.candHops = maxIdx, 1
|
|
||||||
}
|
|
||||||
snr := maxMag / (d.noise + 1e-9)
|
|
||||||
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we
|
|
||||||
// don't eat the first element of a strong signal), a marginal/weak tone
|
|
||||||
// locks after a couple of stable hops (so we don't lock onto pure noise).
|
|
||||||
if snr > d.strongSNR || (d.candHops >= 2 && snr > d.acqSNR) {
|
|
||||||
d.lockIdx = maxIdx
|
|
||||||
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
|
|
||||||
d.quietHops = 0
|
|
||||||
d.markCount = 0 // relearn WPM fast for this new signal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if d.lockIdx >= 0 {
|
|
||||||
d.lastPitch = d.freqs[d.lockIdx]
|
|
||||||
} else {
|
|
||||||
d.lastPitch = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// step runs the adaptive envelope on the locked bin and the timing state
|
|
||||||
// machine, one hop. The envelope adapts to the signal level (not an absolute
|
|
||||||
// threshold), so weak and strong signals both key correctly.
|
|
||||||
func (d *Decoder) step() {
|
|
||||||
on := false
|
|
||||||
if d.lockIdx >= 0 {
|
|
||||||
m := d.mags[d.lockIdx]
|
|
||||||
// Peak: fast attack, slow release.
|
|
||||||
if m > d.peak {
|
|
||||||
d.peak += (m - d.peak) * 0.4
|
|
||||||
} else {
|
|
||||||
d.peak += (m - d.peak) * 0.02
|
|
||||||
}
|
|
||||||
// Floor: drops fast toward the signal, but only RISES between marks (when
|
|
||||||
// keyed up). Letting the floor rise during a long dash would shrink the
|
|
||||||
// span until the dash drops below the threshold and fragments into dots —
|
|
||||||
// the cause of the "all dots" garbage on a strong clean signal.
|
|
||||||
if m < d.floor {
|
|
||||||
d.floor += (m - d.floor) * 0.4
|
|
||||||
} else if !d.state {
|
|
||||||
d.floor += (m - d.floor) * 0.02
|
|
||||||
}
|
|
||||||
span := d.peak - d.floor
|
|
||||||
// The frozen floor already stops dashes fragmenting, so keep balanced
|
|
||||||
// thresholds: low enough that short inter-element GAPS are still seen
|
|
||||||
// (otherwise elements merge into >7-symbol runs that decode to nothing).
|
|
||||||
if span > d.floor*0.3+1e-9 {
|
|
||||||
onTh := d.floor + 0.55*span
|
|
||||||
offTh := d.floor + 0.35*span
|
|
||||||
if d.state {
|
|
||||||
on = m > offTh
|
|
||||||
} else {
|
|
||||||
on = m > onTh
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Release the lock after a long quiet so we can retune to a new signal.
|
|
||||||
if on {
|
|
||||||
d.quietHops = 0
|
|
||||||
} else {
|
|
||||||
d.quietHops++
|
|
||||||
if d.quietHops > d.relockHops {
|
|
||||||
// End of the over: flush any pending character and drop a word
|
|
||||||
// space so the next transmission starts a fresh word (the word-gap
|
|
||||||
// timer above can't fire once the lock is gone).
|
|
||||||
if len(d.elem) > 0 && !d.charEmitted {
|
|
||||||
d.flushChar()
|
|
||||||
d.charEmitted = true
|
|
||||||
}
|
|
||||||
if !d.wordEmitted && d.onChar != nil {
|
|
||||||
d.onChar(" ")
|
|
||||||
d.wordEmitted = true
|
|
||||||
}
|
|
||||||
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if on == d.state {
|
|
||||||
d.stateHops++
|
|
||||||
if !d.state {
|
|
||||||
d.spaceProgress()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if d.state {
|
|
||||||
d.endMark(d.stateHops)
|
|
||||||
}
|
|
||||||
d.state = on
|
|
||||||
d.stateHops = 1
|
|
||||||
if on {
|
|
||||||
d.charEmitted, d.wordEmitted = false, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
d.emitStatus(on)
|
|
||||||
}
|
|
||||||
|
|
||||||
// endMark classifies a finished key-down run as a dot or dash and adapts the
|
|
||||||
// dot-length estimate. Runs shorter than a third of a dot are rejected as
|
|
||||||
// clicks/noise.
|
|
||||||
func (d *Decoder) endMark(hops int) {
|
|
||||||
h := float64(hops)
|
|
||||||
// Reject clicks/noise: shorter than a third of a dot AND an absolute floor
|
|
||||||
// of ~4 hops (~16 ms, i.e. faster than ~75 WPM) so noise can't drag the
|
|
||||||
// dot-length estimate down to the clamp (which produced 100 WPM garbage).
|
|
||||||
if h < d.dotHops*0.35 || h < 4 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if h > d.dotHops*2 {
|
|
||||||
d.elem = append(d.elem, '-')
|
|
||||||
d.adaptDot(h / 3)
|
|
||||||
} else {
|
|
||||||
d.elem = append(d.elem, '.')
|
|
||||||
d.adaptDot(h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
|
|
||||||
// to ~5–60 WPM). The EMA is deliberately GENTLE (0.2) and NOT accelerated on the
|
|
||||||
// opening marks: a fast alpha let short noise blips (misclassified as dots) drag
|
|
||||||
// the dot-length down to the clamp within a few marks — the "60 WPM, all dits"
|
|
||||||
// garbage. The slow EMA is self-correcting because genuine marks pull it back up.
|
|
||||||
func (d *Decoder) adaptDot(obs float64) {
|
|
||||||
const alpha = 0.2
|
|
||||||
d.markCount++
|
|
||||||
d.dotHops = d.dotHops*(1-alpha) + obs*alpha
|
|
||||||
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
|
|
||||||
d.dotHops = 5
|
|
||||||
}
|
|
||||||
if d.dotHops > 55 {
|
|
||||||
d.dotHops = 55
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// spaceProgress flushes the current character once the gap exceeds a character
|
|
||||||
// gap, and a word space once it exceeds a word gap.
|
|
||||||
func (d *Decoder) spaceProgress() {
|
|
||||||
g := float64(d.stateHops)
|
|
||||||
if !d.charEmitted && g > d.dotHops*2 {
|
|
||||||
d.flushChar()
|
|
||||||
d.charEmitted = true
|
|
||||||
}
|
|
||||||
if !d.wordEmitted && g > d.dotHops*5 {
|
|
||||||
if d.onChar != nil {
|
|
||||||
d.onChar(" ")
|
|
||||||
}
|
|
||||||
d.wordEmitted = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// flushChar looks up the accumulated element string and emits the character.
|
|
||||||
func (d *Decoder) flushChar() {
|
|
||||||
if len(d.elem) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if c, ok := morse[string(d.elem)]; ok {
|
|
||||||
if d.onChar != nil {
|
|
||||||
d.onChar(string(c))
|
|
||||||
}
|
|
||||||
} else if d.onChar != nil && len(d.elem) <= 7 {
|
|
||||||
// Only flag a genuinely Morse-shaped but unknown char with "?". An
|
|
||||||
// over-long element run is noise — drop it silently rather than spam "?".
|
|
||||||
d.onChar("?")
|
|
||||||
}
|
|
||||||
d.elem = d.elem[:0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Decoder) emitStatus(on bool) {
|
|
||||||
d.sinceStatus++
|
|
||||||
if d.sinceStatus < d.statusEvery || d.onStatus == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.sinceStatus = 0
|
|
||||||
hopMs := float64(d.hop) / float64(d.fs) * 1000
|
|
||||||
wpm := 0
|
|
||||||
if d.dotHops > 0 {
|
|
||||||
wpm = int(math.Round(1200 / (d.dotHops * hopMs)))
|
|
||||||
}
|
|
||||||
d.onStatus(Status{WPM: wpm, Pitch: int(math.Round(d.lastPitch)), Level: d.lastRMS, Active: on})
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
package cwdecode
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// reverse Morse map for the synthesizer.
|
|
||||||
func charToMorse() map[byte]string {
|
|
||||||
m := map[byte]string{}
|
|
||||||
for code, ch := range morse {
|
|
||||||
m[ch] = code
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// keyMessage synthesizes a clean keyed tone for msg at the given WPM/pitch.
|
|
||||||
func keyMessage(msg string, fs, wpm int, pitch float64) []int16 {
|
|
||||||
return keyMessageAmp(msg, fs, wpm, pitch, 9000)
|
|
||||||
}
|
|
||||||
|
|
||||||
func keyMessageAmp(msg string, fs, wpm int, pitch, amp float64) []int16 {
|
|
||||||
dot := fs * 1200 / (wpm * 1000) // samples per dot
|
|
||||||
c2m := charToMorse()
|
|
||||||
var out []int16
|
|
||||||
phase := 0.0
|
|
||||||
dphi := 2 * math.Pi * pitch / float64(fs)
|
|
||||||
|
|
||||||
tone := func(n int) {
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
out = append(out, int16(amp*math.Sin(phase)))
|
|
||||||
phase += dphi
|
|
||||||
}
|
|
||||||
}
|
|
||||||
silence := func(n int) {
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
out = append(out, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
silence(fs / 4) // 250 ms lead-in for AGC warmup
|
|
||||||
for i := 0; i < len(msg); i++ {
|
|
||||||
ch := msg[i]
|
|
||||||
if ch == ' ' {
|
|
||||||
silence(7 * dot)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
code := c2m[ch]
|
|
||||||
for j := 0; j < len(code); j++ {
|
|
||||||
if code[j] == '.' {
|
|
||||||
tone(dot)
|
|
||||||
} else {
|
|
||||||
tone(3 * dot)
|
|
||||||
}
|
|
||||||
silence(dot) // inter-element gap
|
|
||||||
}
|
|
||||||
silence(3 * dot) // inter-character gap (on top of the trailing element gap)
|
|
||||||
}
|
|
||||||
silence(fs / 4)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeCleanSignal(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
|
|
||||||
// Repeat so AGC warm-up only costs the first word.
|
|
||||||
samples := keyMessage("PARIS PARIS PARIS", fs, 22, 700)
|
|
||||||
// Feed in small chunks like the live capture would.
|
|
||||||
for i := 0; i < len(samples); i += 256 {
|
|
||||||
end := i + 256
|
|
||||||
if end > len(samples) {
|
|
||||||
end = len(samples)
|
|
||||||
}
|
|
||||||
d.Process(samples[i:end])
|
|
||||||
}
|
|
||||||
|
|
||||||
got := strings.ToUpper(sb.String())
|
|
||||||
if !strings.Contains(got, "PARIS") {
|
|
||||||
t.Fatalf("decoded %q, want it to contain PARIS", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeWithQRM(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
// Target at 700 Hz; a strong interfering keyed signal at 950 Hz, slightly
|
|
||||||
// quieter, sending different text. The pitch lock should hold on the target.
|
|
||||||
target := keyMessageAmp("PARIS PARIS PARIS", fs, 20, 700, 9000)
|
|
||||||
qrm := keyMessageAmp("BK DE QRZ QRZ TEST", fs, 26, 950, 6500)
|
|
||||||
mix := make([]int16, len(target))
|
|
||||||
for i := range target {
|
|
||||||
v := int(target[i])
|
|
||||||
if i < len(qrm) {
|
|
||||||
v += int(qrm[i])
|
|
||||||
}
|
|
||||||
if v > 32767 {
|
|
||||||
v = 32767
|
|
||||||
} else if v < -32768 {
|
|
||||||
v = -32768
|
|
||||||
}
|
|
||||||
mix[i] = int16(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
for i := 0; i < len(mix); i += 256 {
|
|
||||||
end := i + 256
|
|
||||||
if end > len(mix) {
|
|
||||||
end = len(mix)
|
|
||||||
}
|
|
||||||
d.Process(mix[i:end])
|
|
||||||
}
|
|
||||||
got := strings.ToUpper(sb.String())
|
|
||||||
if !strings.Contains(got, "PARIS") {
|
|
||||||
t.Fatalf("with QRM, decoded %q, want it to contain PARIS", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeFirstCharStrong(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
// Strong signal: the very first element (T = a dash) must not be eaten by
|
|
||||||
// lock acquisition. Output should begin with the first character.
|
|
||||||
samples := keyMessageAmp("TEST DE", fs, 20, 700, 16000)
|
|
||||||
for i := 0; i < len(samples); i += 200 {
|
|
||||||
end := i + 200
|
|
||||||
if end > len(samples) {
|
|
||||||
end = len(samples)
|
|
||||||
}
|
|
||||||
d.Process(samples[i:end])
|
|
||||||
}
|
|
||||||
got := strings.ToUpper(strings.TrimSpace(sb.String()))
|
|
||||||
if !strings.HasPrefix(got, "TEST") {
|
|
||||||
t.Fatalf("first chars lost on a strong signal: decoded %q, want it to start with TEST", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeWithAmplitudeRipple(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
// A real signal's tone amplitude wobbles within a mark; if the floor chases
|
|
||||||
// it, dashes fragment into dots ("all dots" garbage). Apply ±30% ripple.
|
|
||||||
samples := keyMessageAmp("CQ TEST DE OM", fs, 24, 800, 10000)
|
|
||||||
rp := 0.0
|
|
||||||
for i := range samples {
|
|
||||||
rp += 2 * math.Pi * 35 / float64(fs) // 35 Hz amplitude wobble
|
|
||||||
samples[i] = int16(float64(samples[i]) * (1 + 0.3*math.Sin(rp)))
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
for i := 0; i < len(samples); i += 256 {
|
|
||||||
end := i + 256
|
|
||||||
if end > len(samples) {
|
|
||||||
end = len(samples)
|
|
||||||
}
|
|
||||||
d.Process(samples[i:end])
|
|
||||||
}
|
|
||||||
got := strings.ToUpper(sb.String())
|
|
||||||
if !strings.Contains(got, "TEST DE OM") {
|
|
||||||
t.Fatalf("dashes fragmented under amplitude ripple: decoded %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeCQFixedPitch(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
d.SetTarget(700) // fixed pitch like the user's manual override
|
|
||||||
samples := keyMessageAmp("CQ CQ CQ DE OM", fs, 26, 700, 9000)
|
|
||||||
for i := 0; i < len(samples); i += 200 {
|
|
||||||
end := i + 200
|
|
||||||
if end > len(samples) {
|
|
||||||
end = len(samples)
|
|
||||||
}
|
|
||||||
d.Process(samples[i:end])
|
|
||||||
}
|
|
||||||
got := strings.ToUpper(sb.String())
|
|
||||||
if n := strings.Count(got, "CQ"); n < 2 {
|
|
||||||
t.Fatalf("first element of CQ dropped: decoded %q (only %d CQ)", got, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeNumbersAndProsign(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
samples := keyMessage("TEST 599 TEST", fs, 18, 650)
|
|
||||||
for i := 0; i < len(samples); i += 200 {
|
|
||||||
end := i + 200
|
|
||||||
if end > len(samples) {
|
|
||||||
end = len(samples)
|
|
||||||
}
|
|
||||||
d.Process(samples[i:end])
|
|
||||||
}
|
|
||||||
got := strings.ToUpper(sb.String())
|
|
||||||
if !strings.Contains(got, "599") {
|
|
||||||
t.Fatalf("decoded %q, want it to contain 599", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+19
-7
@@ -198,14 +198,26 @@ func migrate(conn *sql.DB, translate func(string) string) error {
|
|||||||
}
|
}
|
||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
|
|
||||||
for _, name := range names {
|
// Fetch every applied migration name in ONE query rather than one round-trip
|
||||||
var dummy string
|
// per migration. On a high-latency remote MySQL those 20+ SELECTs dominated the
|
||||||
err := conn.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&dummy)
|
// connect time (each RTT × migration count) — the whole logbook connect could
|
||||||
if err == nil {
|
// take tens of seconds. One SELECT + an in-memory set is effectively instant.
|
||||||
continue // already applied
|
applied := map[string]bool{}
|
||||||
|
if rows, err := conn.Query(`SELECT name FROM schema_migrations`); err == nil {
|
||||||
|
for rows.Next() {
|
||||||
|
var n string
|
||||||
|
if rows.Scan(&n) == nil {
|
||||||
|
applied[n] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err != sql.ErrNoRows {
|
rows.Close()
|
||||||
return fmt.Errorf("check migration %s: %w", name, err)
|
} else {
|
||||||
|
return fmt.Errorf("read applied migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
if applied[name] {
|
||||||
|
continue // already applied
|
||||||
}
|
}
|
||||||
content, err := migrationsFS.ReadFile("migrations/" + name)
|
content, err := migrationsFS.ReadFile("migrations/" + name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Materialised award references per QSO. As soon as a QSO matches an award
|
||||||
|
-- (via the operator's award definitions) or a reference is set by hand, the
|
||||||
|
-- resolved reference(s) are stored here as a compact JSON object keyed by award
|
||||||
|
-- code, e.g. {"DDFM":"74","WAJA":"12"}. The grid's per-award columns then read
|
||||||
|
-- straight from the row like any other column instead of recomputing the whole
|
||||||
|
-- award engine on every page load — much faster, and easy to display anywhere
|
||||||
|
-- (including the shared MySQL logbook). Kept in step by the app: written on log
|
||||||
|
-- / edit / UDP-import and bulk-recomputed when an award definition or reference
|
||||||
|
-- list changes. SQLite ADD COLUMN is metadata-only, fast even on large logbooks.
|
||||||
|
ALTER TABLE qso ADD COLUMN award_refs TEXT;
|
||||||
+20
-5
@@ -137,10 +137,6 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
|||||||
if !validDBIdent(name) {
|
if !validDBIdent(name) {
|
||||||
return nil, fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
|
return nil, fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
|
||||||
}
|
}
|
||||||
// Ensure the database exists (connect server-level first).
|
|
||||||
if err := PingMySQL(c); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
conn, err := sql.Open("mysql", c.dsn())
|
conn, err := sql.Open("mysql", c.dsn())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open mysql: %w", err)
|
return nil, fmt.Errorf("open mysql: %w", err)
|
||||||
@@ -157,9 +153,28 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
|||||||
// (surfacing as "Unknown database"). Idle connections are still recycled
|
// (surfacing as "Unknown database"). Idle connections are still recycled
|
||||||
// after 90s, and the driver retries stale pooled connections.
|
// after 90s, and the driver retries stale pooled connections.
|
||||||
conn.SetConnMaxLifetime(0)
|
conn.SetConnMaxLifetime(0)
|
||||||
|
// Connect DIRECTLY to the database first. Only if that fails — the DB doesn't
|
||||||
|
// exist yet (first-time setup) or the server is unreachable — do we pay for the
|
||||||
|
// server-level connect + CREATE DATABASE (two more handshakes). On a normal
|
||||||
|
// startup against an existing DB this halves the connection round-trips, which
|
||||||
|
// matters a lot on a high-latency remote MySQL (each handshake is several RTTs).
|
||||||
if err := conn.Ping(); err != nil {
|
if err := conn.Ping(); err != nil {
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
return nil, fmt.Errorf("connect to %s: %w", name, err)
|
if perr := PingMySQL(c); perr != nil {
|
||||||
|
return nil, perr // creates the DB (or returns a clear "cannot create" error)
|
||||||
|
}
|
||||||
|
conn, err = sql.Open("mysql", c.dsn())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open mysql: %w", err)
|
||||||
|
}
|
||||||
|
conn.SetMaxOpenConns(50)
|
||||||
|
conn.SetMaxIdleConns(10)
|
||||||
|
conn.SetConnMaxIdleTime(90 * time.Second)
|
||||||
|
conn.SetConnMaxLifetime(0)
|
||||||
|
if err := conn.Ping(); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("connect to %s: %w", name, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Set the dialect before migrating so the runner takes the MySQL path
|
// Set the dialect before migrating so the runner takes the MySQL path
|
||||||
// (per-statement, idempotent) rather than the SQLite transaction path.
|
// (per-statement, idempotent) rather than the SQLite transaction path.
|
||||||
|
|||||||
+45
-18
@@ -89,6 +89,9 @@ type Manager struct {
|
|||||||
rnd *rand.Rand
|
rnd *rand.Rand
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxUploadAttempts bounds retries of a transient upload failure.
|
||||||
|
const maxUploadAttempts = 4
|
||||||
|
|
||||||
func NewManager(deps Deps) *Manager {
|
func NewManager(deps Deps) *Manager {
|
||||||
if deps.Client == nil {
|
if deps.Client == nil {
|
||||||
deps.Client = &http.Client{Timeout: 20 * time.Second}
|
deps.Client = &http.Client{Timeout: 20 * time.Second}
|
||||||
@@ -101,6 +104,24 @@ func NewManager(deps Deps) *Manager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attemptUpload uploads a QSO in its OWN goroutine and, on a TRANSIENT failure
|
||||||
|
// (rate-limit / network), re-arms itself with exponential back-off. Each upload is
|
||||||
|
// independent — never serialised through a shared worker, because a single slow
|
||||||
|
// upload (LoTW signs via TQSL; a service on a 30 s timeout) would otherwise block
|
||||||
|
// every following QSO's upload and strand them all at "R" (the regression that hit
|
||||||
|
// the operator on the newest build while everyone on the old concurrent path was
|
||||||
|
// fine).
|
||||||
|
func (m *Manager) attemptUpload(svc Service, id int64, cfg ServiceConfig, attempt int) {
|
||||||
|
go func() {
|
||||||
|
ok, retryable := m.upload(svc, id, cfg)
|
||||||
|
if !ok && retryable && attempt+1 < maxUploadAttempts {
|
||||||
|
backoff := time.Duration(1<<uint(attempt)) * time.Second // 1s, 2s, 4s…
|
||||||
|
m.logf("extsvc: %s upload of QSO %d will retry (attempt %d) in %s", svc, id, attempt+2, backoff)
|
||||||
|
time.AfterFunc(backoff, func() { m.attemptUpload(svc, id, cfg, attempt+1) })
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) logf(format string, args ...any) {
|
func (m *Manager) logf(format string, args ...any) {
|
||||||
if m.deps.Logf != nil {
|
if m.deps.Logf != nil {
|
||||||
m.deps.Logf(format, args...)
|
m.deps.Logf(format, args...)
|
||||||
@@ -175,15 +196,17 @@ func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
|||||||
m.scheduleUpload(svc, id, cfg)
|
m.scheduleUpload(svc, id, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// scheduleUpload either uploads now (immediate) or arms a timer (delayed).
|
// scheduleUpload uploads now (immediate) or after a random fuse (delayed). Each
|
||||||
|
// upload runs in its own goroutine (attemptUpload) — never serialised — so a slow
|
||||||
|
// one never holds up the rest.
|
||||||
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
||||||
if cfg.UploadMode == ModeDelayed {
|
if cfg.UploadMode == ModeDelayed {
|
||||||
d := m.delaySeconds()
|
d := m.delaySeconds()
|
||||||
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
|
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
|
||||||
time.AfterFunc(d, func() { m.upload(svc, id, cfg) })
|
time.AfterFunc(d, func() { m.attemptUpload(svc, id, cfg, 0) })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go m.upload(svc, id, cfg)
|
m.attemptUpload(svc, id, cfg, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// onCloseServices returns the services configured for on-close auto-upload,
|
// onCloseServices returns the services configured for on-close auto-upload,
|
||||||
@@ -243,25 +266,25 @@ func (m *Manager) FlushOnClose() int {
|
|||||||
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
||||||
case ServiceQRZ:
|
case ServiceQRZ:
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if m.upload(svc, id, cfg.QRZ) {
|
if ok, _ := m.upload(svc, id, cfg.QRZ); ok {
|
||||||
uploaded++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ServiceClublog:
|
case ServiceClublog:
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if m.upload(svc, id, cfg.Clublog) {
|
if ok, _ := m.upload(svc, id, cfg.Clublog); ok {
|
||||||
uploaded++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ServiceHRDLog:
|
case ServiceHRDLog:
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if m.upload(svc, id, cfg.HRDLog) {
|
if ok, _ := m.upload(svc, id, cfg.HRDLog); ok {
|
||||||
uploaded++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ServiceEQSL:
|
case ServiceEQSL:
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if m.upload(svc, id, cfg.EQSL) {
|
if ok, _ := m.upload(svc, id, cfg.EQSL); ok {
|
||||||
uploaded++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,12 +335,16 @@ func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
|
|||||||
// upload performs the actual push and returns true on success. It builds a
|
// upload performs the actual push and returns true on success. It builds a
|
||||||
// fresh, lifecycle-independent context so a delayed upload still completes
|
// fresh, lifecycle-independent context so a delayed upload still completes
|
||||||
// even if it fires close to shutdown.
|
// even if it fires close to shutdown.
|
||||||
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
// upload performs one upload. It returns ok=true when the QSO was uploaded (and
|
||||||
|
// marked), and retryable=true when it failed in a way worth retrying later (an
|
||||||
|
// HTTP/service error such as a rate-limit 403) as opposed to a permanent skip
|
||||||
|
// (not eligible, wrong station callsign, no record).
|
||||||
|
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, retryable bool) {
|
||||||
// Skip QSOs that aren't eligible (already sent, or sent status doesn't
|
// Skip QSOs that aren't eligible (already sent, or sent status doesn't
|
||||||
// match the configured Upload flag).
|
// match the configured Upload flag).
|
||||||
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(svc, id) {
|
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(svc, id) {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (not eligible)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (not eligible)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Station-callsign guard. Each logbook belongs to one callsign:
|
// Station-callsign guard. Each logbook belongs to one callsign:
|
||||||
@@ -345,7 +372,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
if m.deps.NotifyError != nil {
|
if m.deps.NotifyError != nil {
|
||||||
m.deps.NotifyError(svc, id, err)
|
m.deps.NotifyError(svc, id, err)
|
||||||
}
|
}
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +387,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
|
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
|
||||||
case ServiceClublog:
|
case ServiceClublog:
|
||||||
@@ -369,7 +396,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
record, ok := m.deps.BuildADIF(id, "")
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
||||||
case ServiceLoTW:
|
case ServiceLoTW:
|
||||||
@@ -378,7 +405,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadLoTW(ctx, cfg, "", record)
|
res, err = UploadLoTW(ctx, cfg, "", record)
|
||||||
case ServiceHRDLog:
|
case ServiceHRDLog:
|
||||||
@@ -387,7 +414,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
record, ok := m.deps.BuildADIF(id, "")
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
|
res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
|
||||||
case ServiceEQSL:
|
case ServiceEQSL:
|
||||||
@@ -396,11 +423,11 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
record, ok := m.deps.BuildADIF(id, "")
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
|
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
|
||||||
default:
|
default:
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil || !res.OK {
|
if err != nil || !res.OK {
|
||||||
@@ -411,12 +438,12 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
if m.deps.NotifyError != nil {
|
if m.deps.NotifyError != nil {
|
||||||
m.deps.NotifyError(svc, id, err)
|
m.deps.NotifyError(svc, id, err)
|
||||||
}
|
}
|
||||||
return false
|
return false, true // transient (rate-limit / network) → worth a retry
|
||||||
}
|
}
|
||||||
|
|
||||||
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
|
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
|
||||||
if m.deps.MarkUploaded != nil {
|
if m.deps.MarkUploaded != nil {
|
||||||
m.deps.MarkUploaded(svc, id, res.LogID)
|
m.deps.MarkUploaded(svc, id, res.LogID)
|
||||||
}
|
}
|
||||||
return true
|
return true, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ type Event struct {
|
|||||||
DecodeFreqHz int64 // RF frequency (dial + audio offset)
|
DecodeFreqHz int64 // RF frequency (dial + audio offset)
|
||||||
DecodeSNR int // reported SNR (dB)
|
DecodeSNR int // reported SNR (dB)
|
||||||
DecodeCQ bool // the decode was a CQ
|
DecodeCQ bool // the decode was a CQ
|
||||||
|
|
||||||
|
// ClearCall is set when a WSJT/JTDX/MSHV Status message reports an EMPTY DX
|
||||||
|
// Call after previously reporting one — i.e. the operator cleared the call in
|
||||||
|
// the digital app. OpsLog clears its entry to match.
|
||||||
|
ClearCall bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is a single inbound UDP listener.
|
// Server is a single inbound UDP listener.
|
||||||
@@ -64,7 +69,8 @@ type Server struct {
|
|||||||
stopped bool
|
stopped bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
||||||
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
||||||
|
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
|
||||||
}
|
}
|
||||||
|
|
||||||
func newServer(cfg Config, out chan<- Event) *Server {
|
func newServer(cfg Config, out chan<- Event) *Server {
|
||||||
@@ -213,6 +219,17 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
ev.Mode = w.Mode
|
ev.Mode = w.Mode
|
||||||
ev.FreqHz = w.FreqHz
|
ev.FreqHz = w.FreqHz
|
||||||
ev.LoggedADIF = w.LoggedADIF
|
ev.LoggedADIF = w.LoggedADIF
|
||||||
|
// A Status with an empty DX Call, right after one that had a call, means the
|
||||||
|
// operator cleared it in WSJT-X / JTDX / MSHV. Fire ONE clear (tracked per
|
||||||
|
// server) — an idle app sends empty Status every second, and we must not
|
||||||
|
// re-clear (which would fight a manual entry) on each of those.
|
||||||
|
s.mu.Lock()
|
||||||
|
prev := s.lastDX
|
||||||
|
s.lastDX = w.DXCall
|
||||||
|
s.mu.Unlock()
|
||||||
|
if w.DXCall == "" && prev != "" {
|
||||||
|
ev.ClearCall = true
|
||||||
|
}
|
||||||
case ServiceADIF:
|
case ServiceADIF:
|
||||||
// JTAlert / GridTracker forward a text ADIF record after a QSO is
|
// JTAlert / GridTracker forward a text ADIF record after a QSO is
|
||||||
// logged. Guard against keep-alive / non-ADIF chatter on the socket:
|
// logged. Guard against keep-alive / non-ADIF chatter on the socket:
|
||||||
@@ -268,8 +285,9 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Empty events are useless; skip.
|
// Empty events are useless; skip — EXCEPT a clear signal, which is meant to be
|
||||||
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" {
|
// empty (the DX Call was cleared in the digital app).
|
||||||
|
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && !ev.ClearCall {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
|
|||||||
+324
-8
@@ -197,6 +197,14 @@ type QSO struct {
|
|||||||
// ADIF field names (e.g. "MS_SHOWER"); values are the raw string content.
|
// ADIF field names (e.g. "MS_SHOWER"); values are the raw string content.
|
||||||
Extras map[string]string `json:"extras,omitempty"`
|
Extras map[string]string `json:"extras,omitempty"`
|
||||||
|
|
||||||
|
// AwardRefs is the materialised award-reference JSON for this QSO — a compact
|
||||||
|
// object keyed by award code, e.g. {"DDFM":"74","WAJA":"12"}. Derived (the app
|
||||||
|
// computes it on log/edit and bulk-recomputes it when awards change) and stored
|
||||||
|
// so the grid's award columns read straight from the row. Written ONLY via
|
||||||
|
// SetAwardRefs, never through the normal insert/update column list, so an edit
|
||||||
|
// that doesn't know about it can't clobber it.
|
||||||
|
AwardRefs string `json:"award_refs,omitempty"`
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -250,7 +258,10 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
|
|||||||
credit_granted, credit_submitted, my_arrl_sect, my_vucc_grids,
|
credit_granted, credit_submitted, my_arrl_sect, my_vucc_grids,
|
||||||
extras_json`
|
extras_json`
|
||||||
|
|
||||||
const selectCols = `id, ` + columnList + `, created_at, updated_at`
|
// award_refs is read here but is NOT part of columnList (the insert/update
|
||||||
|
// write path) — it is a derived cache written only via SetAwardRefs, so a
|
||||||
|
// normal QSO write can never clobber it.
|
||||||
|
const selectCols = `id, ` + columnList + `, award_refs, created_at, updated_at`
|
||||||
|
|
||||||
// columnCount is derived from columnList at init so they can never drift.
|
// columnCount is derived from columnList at init so they can never drift.
|
||||||
var columnCount = countColumns(columnList)
|
var columnCount = countColumns(columnList)
|
||||||
@@ -724,6 +735,10 @@ var bulkEditableCols = map[string]bool{
|
|||||||
"my_antenna": true,
|
"my_antenna": true,
|
||||||
"my_sig": true,
|
"my_sig": true,
|
||||||
"my_sig_info": true,
|
"my_sig_info": true,
|
||||||
|
"my_name": true,
|
||||||
|
"my_arrl_sect": true,
|
||||||
|
"my_darc_dok": true,
|
||||||
|
"my_vucc_grids": true,
|
||||||
// Contest — the exchange/label fields that are constant across a run.
|
// Contest — the exchange/label fields that are constant across a run.
|
||||||
// (srx/stx serial numbers stay excluded: they are per-QSO.)
|
// (srx/stx serial numbers stay excluded: they are per-QSO.)
|
||||||
"contest_id": true,
|
"contest_id": true,
|
||||||
@@ -777,6 +792,81 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetExtra merges ONE key into a QSO's extras JSON without rewriting the rest of
|
||||||
|
// the row. A slow caller that only wants to stamp its own app field (e-mailing a
|
||||||
|
// QSL card, saving a recording) must not do a full-row Update: the row it read may
|
||||||
|
// be seconds stale, and writing it all back silently reverts any column another
|
||||||
|
// action changed meanwhile — e.g. an auto-upload flipping clublog_qso_upload_status
|
||||||
|
// from R to Y. Touching only `extras` makes that impossible. Empty value deletes
|
||||||
|
// the key.
|
||||||
|
func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error {
|
||||||
|
if id == 0 || strings.TrimSpace(key) == "" {
|
||||||
|
return fmt.Errorf("missing id or key")
|
||||||
|
}
|
||||||
|
var extrasJSON sql.NullString
|
||||||
|
if err := r.db.QueryRowContext(ctx, `SELECT extras_json FROM qso WHERE id = ?`, id).Scan(&extrasJSON); err != nil {
|
||||||
|
return fmt.Errorf("load extras: %w", err)
|
||||||
|
}
|
||||||
|
m := decodeExtras(extrasJSON.String)
|
||||||
|
if m == nil {
|
||||||
|
m = map[string]string{}
|
||||||
|
}
|
||||||
|
if value == "" {
|
||||||
|
delete(m, key)
|
||||||
|
} else {
|
||||||
|
m[key] = value
|
||||||
|
}
|
||||||
|
if _, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE qso SET extras_json = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
encodeExtras(m), db.NowISO(), id); err != nil {
|
||||||
|
return fmt.Errorf("set extra %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAwardRefs stores the materialised award-reference JSON for one QSO. Like
|
||||||
|
// SetExtra it is a targeted single-column UPDATE — never a full-row write — so it
|
||||||
|
// cannot clobber a field another action changed meanwhile. updated_at is left
|
||||||
|
// UNTOUCHED on purpose: award_refs is a derived cache, and bumping updated_at
|
||||||
|
// would masquerade as a real edit (re-triggering uploads / sync that watch it).
|
||||||
|
func (r *Repo) SetAwardRefs(ctx context.Context, id int64, jsonStr string) error {
|
||||||
|
if id == 0 {
|
||||||
|
return fmt.Errorf("missing id")
|
||||||
|
}
|
||||||
|
if _, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE qso SET award_refs = ? WHERE id = ?`, jsonStr, id); err != nil {
|
||||||
|
return fmt.Errorf("set award_refs: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAwardRefsBatch stores materialised award refs for many QSOs in a single
|
||||||
|
// transaction — used by the bulk recompute so a large logbook on a remote MySQL
|
||||||
|
// is one round-trip's worth of work, not N. Like SetAwardRefs it touches only the
|
||||||
|
// award_refs column and leaves updated_at alone (derived cache). A nil/empty map
|
||||||
|
// is a no-op.
|
||||||
|
func (r *Repo) SetAwardRefsBatch(ctx context.Context, byID map[int64]string) error {
|
||||||
|
if len(byID) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
stmt, err := tx.PrepareContext(ctx, `UPDATE qso SET award_refs = ? WHERE id = ?`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("prepare: %w", err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
for id, js := range byID {
|
||||||
|
if _, err := stmt.ExecContext(ctx, js, id); err != nil {
|
||||||
|
return fmt.Errorf("set award_refs %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
||||||
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
||||||
if q.ID == 0 {
|
if q.ID == 0 {
|
||||||
@@ -1601,7 +1691,7 @@ func (r *Repo) IterateAll(ctx context.Context, fn func(QSO) error) error {
|
|||||||
// column to this list AND populate it in scanAwardQSO below, or that award will
|
// column to this list AND populate it in scanAwardQSO below, or that award will
|
||||||
// silently see an empty value during stats/computation.
|
// silently see an empty value during stats/computation.
|
||||||
const awardCols = `id, callsign, qso_date, band, freq_hz, mode, ` +
|
const awardCols = `id, callsign, qso_date, band, freq_hz, mode, ` +
|
||||||
`grid, vucc_grids, country, state, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` +
|
`grid, vucc_grids, country, state, cnty, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` +
|
||||||
`name, qth, address, comment, notes, ` +
|
`name, qth, address, comment, notes, ` +
|
||||||
`qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json`
|
`qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json`
|
||||||
|
|
||||||
@@ -1636,6 +1726,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
|
|||||||
qsoDateStr string
|
qsoDateStr string
|
||||||
freqHz sql.NullInt64
|
freqHz sql.NullInt64
|
||||||
grid, vucc, country, state sql.NullString
|
grid, vucc, country, state sql.NullString
|
||||||
|
cnty sql.NullString
|
||||||
cont, iotaRef, sota, pota sql.NullString
|
cont, iotaRef, sota, pota sql.NullString
|
||||||
dxcc, cqz, ituz sql.NullInt64
|
dxcc, cqz, ituz sql.NullInt64
|
||||||
name, qth, address sql.NullString
|
name, qth, address sql.NullString
|
||||||
@@ -1645,7 +1736,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
|
|||||||
)
|
)
|
||||||
if err := s.Scan(
|
if err := s.Scan(
|
||||||
&q.ID, &q.Callsign, &qsoDateStr, &q.Band, &freqHz, &q.Mode,
|
&q.ID, &q.Callsign, &qsoDateStr, &q.Band, &freqHz, &q.Mode,
|
||||||
&grid, &vucc, &country, &state, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota,
|
&grid, &vucc, &country, &state, &cnty, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota,
|
||||||
&name, &qth, &address, &comment, ¬es,
|
&name, &qth, &address, &comment, ¬es,
|
||||||
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
|
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -1660,6 +1751,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
|
|||||||
q.VUCCGrids = vucc.String
|
q.VUCCGrids = vucc.String
|
||||||
q.Country = country.String
|
q.Country = country.String
|
||||||
q.State = state.String
|
q.State = state.String
|
||||||
|
q.County = cnty.String
|
||||||
q.Continent = cont.String
|
q.Continent = cont.String
|
||||||
if cqz.Valid {
|
if cqz.Valid {
|
||||||
v := int(cqz.Int64)
|
v := int(cqz.Int64)
|
||||||
@@ -1778,6 +1870,78 @@ func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error)
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WorkedCountyKeys returns the set of counties already worked, keyed by the
|
||||||
|
// caller-supplied normaliser (award.USCountyKey — passed in to avoid importing
|
||||||
|
// the award package here). Only US-entity QSOs (DXCC 291/110/6) with a county
|
||||||
|
// are considered. Empty keys (unresolvable state/county) are skipped.
|
||||||
|
func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty string) string) (map[string]struct{}, error) {
|
||||||
|
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 != ''`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[string]struct{}, 1024)
|
||||||
|
for rows.Next() {
|
||||||
|
var state, cnty string
|
||||||
|
if err := rows.Scan(&state, &cnty); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if k := keyFn(state, cnty); k != "" {
|
||||||
|
out[k] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkedCallBandModeKeys returns the set of every worked "CALL|BAND|MODE" key
|
||||||
|
// (all upper-cased), loaded in one pass. It backs the in-memory worked-index the
|
||||||
|
// alert engine checks per cluster spot — a DB query per spot cannot keep up with
|
||||||
|
// an FT8 skimmer firehose (and hammers a remote MySQL).
|
||||||
|
func (r *Repo) WorkedCallBandModeKeys(ctx context.Context) (map[string]struct{}, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT upper(callsign), upper(band), upper(mode) FROM qso WHERE callsign != ''`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[string]struct{}, 4096)
|
||||||
|
for rows.Next() {
|
||||||
|
var c, b, m string
|
||||||
|
if err := rows.Scan(&c, &b, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[c+"|"+b+"|"+m] = struct{}{}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkedPOTARefs returns the set of POTA park references already worked
|
||||||
|
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks
|
||||||
|
// (an n-fer); each is added separately.
|
||||||
|
func (r *Repo) WorkedPOTARefs(ctx context.Context) (map[string]struct{}, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT DISTINCT pota_ref FROM qso WHERE pota_ref IS NOT NULL AND pota_ref != ''`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[string]struct{}, 256)
|
||||||
|
for rows.Next() {
|
||||||
|
var ref string
|
||||||
|
if err := rows.Scan(&ref); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, p := range strings.Split(ref, ",") {
|
||||||
|
if p = strings.ToUpper(strings.TrimSpace(p)); p != "" {
|
||||||
|
out[p] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Count returns the total number of QSOs in the database.
|
// Count returns the total number of QSOs in the database.
|
||||||
func (r *Repo) Count(ctx context.Context) (int64, error) {
|
func (r *Repo) Count(ctx context.Context) (int64, error) {
|
||||||
var n int64
|
var n int64
|
||||||
@@ -1785,6 +1949,73 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
|
|||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LastQSOTime returns the start time of the most recently LOGGED QSO for an
|
||||||
|
// operator (highest id wins) — used to seed the live "on air" state at launch so an
|
||||||
|
// operator who just worked someone before (re)starting OpsLog shows online right
|
||||||
|
// away instead of waiting for their next QSO. Empty operator matches every QSO.
|
||||||
|
func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, bool) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 400`)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
||||||
|
for rows.Next() {
|
||||||
|
var oper, dateStr sql.NullString
|
||||||
|
if err := rows.Scan(&oper, &dateStr); err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentRateBreakdown counts, in ONE pass over the most recent rows, QSOs whose
|
||||||
|
// start time falls within each trailing window from `now` — for a specific operator
|
||||||
|
// (their own rate, `op`) AND for ALL operators combined (the team/station rate,
|
||||||
|
// `all`). The header rate meter shows both. It scans only recently inserted rows
|
||||||
|
// (ORDER BY id DESC LIMIT), since any QSO in the last hour was inserted recently, so
|
||||||
|
// it stays cheap on a large log. qso_date is parsed with parseTimeLoose (backend-
|
||||||
|
// format agnostic).
|
||||||
|
func (r *Repo) RecentRateBreakdown(ctx context.Context, now time.Time, operator string, windows ...time.Duration) (op []int, all []int, err error) {
|
||||||
|
op = make([]int, len(windows))
|
||||||
|
all = make([]int, len(windows))
|
||||||
|
// 2000 rows covers a full hour even in a busy multi-op run.
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`)
|
||||||
|
if err != nil {
|
||||||
|
return op, all, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
now = now.UTC()
|
||||||
|
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
||||||
|
for rows.Next() {
|
||||||
|
var oper, dateStr sql.NullString
|
||||||
|
if err := rows.Scan(&oper, &dateStr); err != nil {
|
||||||
|
return op, all, err
|
||||||
|
}
|
||||||
|
t := parseTimeLoose(dateStr.String).UTC()
|
||||||
|
if t.IsZero() || t.After(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mine := strings.ToUpper(strings.TrimSpace(oper.String)) == opFilter
|
||||||
|
age := now.Sub(t)
|
||||||
|
for i, w := range windows {
|
||||||
|
if age <= w {
|
||||||
|
all[i]++
|
||||||
|
if mine {
|
||||||
|
op[i]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return op, all, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// ExistingDedupeKeys returns a set of every QSO key currently in the DB,
|
// ExistingDedupeKeys returns a set of every QSO key currently in the DB,
|
||||||
// used by the ADIF importer to skip records that would re-create the
|
// used by the ADIF importer to skip records that would re-create the
|
||||||
// same contact. The key is callsign|YYYY-MM-DDTHH:MM|band|mode — minute
|
// same contact. The key is callsign|YYYY-MM-DDTHH:MM|band|mode — minute
|
||||||
@@ -1954,18 +2185,100 @@ func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, b
|
|||||||
// ConfirmedSets captures which DXCC / band / slot combinations are already
|
// ConfirmedSets captures which DXCC / band / slot combinations are already
|
||||||
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
|
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
|
||||||
// flagged as a NEW DXCC / NEW BAND / NEW SLOT.
|
// flagged as a NEW DXCC / NEW BAND / NEW SLOT.
|
||||||
|
// ConfirmedSets captures confirmed combinations for the QSL Manager's NEW flags.
|
||||||
|
// Modes are grouped into CLASSES (Phone/CW/Digital) — a digital confirmation is a
|
||||||
|
// "new mode/slot" only if no digital mode was confirmed there before (RTTY and FT8
|
||||||
|
// are the same DIGI class). Raw-mode granularity lives only in the cluster/matrix.
|
||||||
type ConfirmedSets struct {
|
type ConfirmedSets struct {
|
||||||
DXCC map[int]bool // dxcc entity confirmed
|
DXCC map[int]bool // dxcc entity confirmed
|
||||||
Band map[string]bool // "dxcc|band"
|
Band map[string]bool // "dxcc|band"
|
||||||
Slot map[string]bool // "dxcc|band|mode"
|
Mode map[string]bool // "dxcc|class"
|
||||||
|
Slot map[string]bool // "dxcc|band|class"
|
||||||
}
|
}
|
||||||
|
|
||||||
// SlotKey / BandKey build the composite keys used in ConfirmedSets.
|
// Key builders for ConfirmedSets. Band is mode-agnostic; Mode/Slot use the class.
|
||||||
func BandKey(dxcc int, band string) string { return fmt.Sprintf("%d|%s", dxcc, strings.ToLower(band)) }
|
func BandKey(dxcc int, band string) string { return fmt.Sprintf("%d|%s", dxcc, strings.ToLower(band)) }
|
||||||
|
func ModeClassKey(dxcc int, mode string) string {
|
||||||
|
return fmt.Sprintf("%d|%s", dxcc, modeClass(mode))
|
||||||
|
}
|
||||||
|
func SlotClassKey(dxcc int, band, mode string) string {
|
||||||
|
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), modeClass(mode))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SlotKey is the RAW-mode slot key, kept for the cluster/matrix new-slot flag.
|
||||||
func SlotKey(dxcc int, band, mode string) string {
|
func SlotKey(dxcc int, band, mode string) string {
|
||||||
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode))
|
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SlotStats is the worked/confirmed tally for the QSL Manager, counting slots by
|
||||||
|
// mode CLASS (Phone / CW / Digital) — NOT raw mode. Raw-mode granularity (RTTY ≠
|
||||||
|
// FT8) is kept only for the cluster/matrix "new slot" flag; the totals here match
|
||||||
|
// how slots are conventionally counted (a band in a class, not in each digital
|
||||||
|
// sub-mode).
|
||||||
|
type SlotStats struct {
|
||||||
|
SlotsWorked int `json:"slots_worked"` // distinct DXCC × band × class, all QSOs
|
||||||
|
SlotsConfirmed int `json:"slots_confirmed"` // distinct DXCC × band × class, confirmed
|
||||||
|
DXCCWorked int `json:"dxcc_worked"` // distinct DXCC entities worked
|
||||||
|
DXCCConfirmed int `json:"dxcc_confirmed"` // distinct DXCC entities confirmed
|
||||||
|
// Per-class slot breakdown (Phone / CW / Digital) so the numbers are checkable.
|
||||||
|
PHWorked int `json:"ph_worked"`
|
||||||
|
PHConfirmed int `json:"ph_confirmed"`
|
||||||
|
CWWorked int `json:"cw_worked"`
|
||||||
|
CWConfirmed int `json:"cw_confirmed"`
|
||||||
|
DIGWorked int `json:"dig_worked"`
|
||||||
|
DIGConfirmed int `json:"dig_confirmed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSlotStats computes the worked/confirmed slot and DXCC tallies in one pass.
|
||||||
|
// "Confirmed" = LoTW or paper QSL received (the award-valid sources).
|
||||||
|
func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,'')),
|
||||||
|
CASE WHEN lotw_rcvd='Y' OR qsl_rcvd='Y' THEN 1 ELSE 0 END
|
||||||
|
FROM qso`)
|
||||||
|
if err != nil {
|
||||||
|
return SlotStats{}, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
dxccW, slotW := map[int]bool{}, map[string]bool{}
|
||||||
|
dxccC, slotC := map[int]bool{}, map[string]bool{}
|
||||||
|
// Per-class distinct slots (worked "w" / confirmed "c").
|
||||||
|
clsW := map[string]map[string]bool{"PH": {}, "CW": {}, "DIG": {}}
|
||||||
|
clsC := map[string]map[string]bool{"PH": {}, "CW": {}, "DIG": {}}
|
||||||
|
for rows.Next() {
|
||||||
|
var dxcc, conf int
|
||||||
|
var band, mode string
|
||||||
|
if err := rows.Scan(&dxcc, &band, &mode, &conf); err != nil {
|
||||||
|
return SlotStats{}, err
|
||||||
|
}
|
||||||
|
if dxcc == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dxccW[dxcc] = true
|
||||||
|
if conf == 1 {
|
||||||
|
dxccC[dxcc] = true
|
||||||
|
}
|
||||||
|
if band == "" {
|
||||||
|
continue // no band → counts for DXCC but not for a slot
|
||||||
|
}
|
||||||
|
key := SlotClassKey(dxcc, band, mode)
|
||||||
|
cl := modeClass(mode)
|
||||||
|
slotW[key] = true
|
||||||
|
clsW[cl][key] = true
|
||||||
|
if conf == 1 {
|
||||||
|
slotC[key] = true
|
||||||
|
clsC[cl][key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SlotStats{
|
||||||
|
SlotsWorked: len(slotW), SlotsConfirmed: len(slotC),
|
||||||
|
DXCCWorked: len(dxccW), DXCCConfirmed: len(dxccC),
|
||||||
|
PHWorked: len(clsW["PH"]), PHConfirmed: len(clsC["PH"]),
|
||||||
|
CWWorked: len(clsW["CW"]), CWConfirmed: len(clsC["CW"]),
|
||||||
|
DIGWorked: len(clsW["DIG"]), DIGConfirmed: len(clsC["DIG"]),
|
||||||
|
}, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// confirmedCols whitelists the received-status columns ConfirmedSlots may
|
// confirmedCols whitelists the received-status columns ConfirmedSlots may
|
||||||
// OR together (guards the dynamic SQL).
|
// OR together (guards the dynamic SQL).
|
||||||
var confirmedCols = map[string]bool{
|
var confirmedCols = map[string]bool{
|
||||||
@@ -1981,7 +2294,7 @@ var confirmedCols = map[string]bool{
|
|||||||
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
|
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
|
||||||
// {qrzcom_qso_download_status}.
|
// {qrzcom_qso_download_status}.
|
||||||
func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets, error) {
|
func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets, error) {
|
||||||
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Slot: map[string]bool{}}
|
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Mode: map[string]bool{}, Slot: map[string]bool{}}
|
||||||
var conds []string
|
var conds []string
|
||||||
for _, c := range cols {
|
for _, c := range cols {
|
||||||
if confirmedCols[c] {
|
if confirmedCols[c] {
|
||||||
@@ -2010,7 +2323,8 @@ func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets
|
|||||||
}
|
}
|
||||||
sets.DXCC[dxcc] = true
|
sets.DXCC[dxcc] = true
|
||||||
sets.Band[BandKey(dxcc, band)] = true
|
sets.Band[BandKey(dxcc, band)] = true
|
||||||
sets.Slot[SlotKey(dxcc, band, mode)] = true
|
sets.Mode[ModeClassKey(dxcc, mode)] = true
|
||||||
|
sets.Slot[SlotClassKey(dxcc, band, mode)] = true
|
||||||
}
|
}
|
||||||
return sets, rows.Err()
|
return sets, rows.Err()
|
||||||
}
|
}
|
||||||
@@ -2114,6 +2428,7 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
creditGranted, creditSubmitted sql.NullString
|
creditGranted, creditSubmitted sql.NullString
|
||||||
myARRLSect, myVUCCGrids sql.NullString
|
myARRLSect, myVUCCGrids sql.NullString
|
||||||
extrasJSON sql.NullString
|
extrasJSON sql.NullString
|
||||||
|
awardRefs sql.NullString
|
||||||
createdStr, updatedStr string
|
createdStr, updatedStr string
|
||||||
)
|
)
|
||||||
if err := s.Scan(
|
if err := s.Scan(
|
||||||
@@ -2141,7 +2456,7 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
|
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
|
||||||
&darcDOK, &myDarcDOK, ®ion, &silentKey, &swl, &qsoComplete, &qsoRandom,
|
&darcDOK, &myDarcDOK, ®ion, &silentKey, &swl, &qsoComplete, &qsoRandom,
|
||||||
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
|
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
|
||||||
&extrasJSON, &createdStr, &updatedStr,
|
&extrasJSON, &awardRefs, &createdStr, &updatedStr,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return QSO{}, fmt.Errorf("scan qso: %w", err)
|
return QSO{}, fmt.Errorf("scan qso: %w", err)
|
||||||
}
|
}
|
||||||
@@ -2338,6 +2653,7 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
q.MyARRLSect = myARRLSect.String
|
q.MyARRLSect = myARRLSect.String
|
||||||
q.MyVUCCGrids = myVUCCGrids.String
|
q.MyVUCCGrids = myVUCCGrids.String
|
||||||
q.Extras = decodeExtras(extrasJSON.String)
|
q.Extras = decodeExtras(extrasJSON.String)
|
||||||
|
q.AwardRefs = awardRefs.String
|
||||||
return q, nil
|
return q, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+157
-9
@@ -24,6 +24,30 @@ type Bucket struct {
|
|||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BandCategory is one band's QSO count split by mode category (CW / phone /
|
||||||
|
// digital), for the per-band mode-split chart.
|
||||||
|
type BandCategory struct {
|
||||||
|
Band string `json:"band"`
|
||||||
|
CW int `json:"cw"`
|
||||||
|
Phone int `json:"phone"`
|
||||||
|
Data int `json:"data"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeCategory buckets a mode into "cw", "phone" or "data" (digital). Voice modes
|
||||||
|
// (SSB and the digital-voice family) count as phone; CW is CW; everything else is
|
||||||
|
// data. Mirrors the frontend's mode colouring.
|
||||||
|
func modeCategory(mode string) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "CW":
|
||||||
|
return "cw"
|
||||||
|
case "SSB", "USB", "LSB", "AM", "FM", "DV", "DIGITALVOICE", "FREEDV", "C4FM", "DSTAR", "FUSION":
|
||||||
|
return "phone"
|
||||||
|
default:
|
||||||
|
return "data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Gap is a stretch with no QSO at all — the off-air periods. In a contest these
|
// Gap is a stretch with no QSO at all — the off-air periods. In a contest these
|
||||||
// are the expensive minutes: they are where the score went.
|
// are the expensive minutes: they are where the score went.
|
||||||
type Gap struct {
|
type Gap struct {
|
||||||
@@ -129,8 +153,9 @@ type Stats struct {
|
|||||||
ConfirmedAny int `json:"confirmed_any"`
|
ConfirmedAny int `json:"confirmed_any"`
|
||||||
|
|
||||||
// Breakdowns, each sorted most → least (bands keep frequency order).
|
// Breakdowns, each sorted most → least (bands keep frequency order).
|
||||||
ByMode []Bucket `json:"by_mode"`
|
ByMode []Bucket `json:"by_mode"`
|
||||||
ByBand []Bucket `json:"by_band"`
|
ByBand []Bucket `json:"by_band"`
|
||||||
|
ByBandCategory []BandCategory `json:"by_band_category"` // per band: CW / phone / data split
|
||||||
ByOperator []Bucket `json:"by_operator"`
|
ByOperator []Bucket `json:"by_operator"`
|
||||||
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
|
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
|
||||||
ByContinent []Bucket `json:"by_continent"`
|
ByContinent []Bucket `json:"by_continent"`
|
||||||
@@ -138,6 +163,14 @@ type Stats struct {
|
|||||||
ByYear []Bucket `json:"by_year"` // chronological
|
ByYear []Bucket `json:"by_year"` // chronological
|
||||||
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
|
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
|
||||||
|
|
||||||
|
// Rolling chronological activity windows (UTC), anchored to the newest QSO, for
|
||||||
|
// the "activity over time" chart's day/week/month/year granularity selector.
|
||||||
|
// Real dates in order (empty buckets are real, just zero) — no cyclical wrap.
|
||||||
|
ByHour []Bucket `json:"by_hour"` // the newest QSO's day, hour by hour (00..23)
|
||||||
|
ByDay7 []Bucket `json:"by_day7"` // the last 7 days ending at the newest QSO
|
||||||
|
ByDay30 []Bucket `json:"by_day30"` // the last 30 days
|
||||||
|
ByMonth12 []Bucket `json:"by_month12"` // the last 12 months
|
||||||
|
|
||||||
// ── Period / contest metrics ──
|
// ── Period / contest metrics ──
|
||||||
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
|
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
|
||||||
// nothing, but across a contest weekend it is the score. The window is the
|
// nothing, but across a contest weekend it is the score. The window is the
|
||||||
@@ -210,9 +243,49 @@ func yes(s string) bool {
|
|||||||
// it is set and no explicit window is given, the window becomes the contest's own
|
// it is set and no explicit window is given, the window becomes the contest's own
|
||||||
// span — so rate, best-hour and off-air figures are computed over the contest
|
// span — so rate, best-hour and off-air figures are computed over the contest
|
||||||
// itself without the operator having to look its dates up.
|
// itself without the operator having to look its dates up.
|
||||||
func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, year int) (Stats, error) {
|
// Operators returns the distinct operators in the log (upper-cased), with "—"
|
||||||
|
// for QSOs the station owner logged himself (empty OPERATOR). Sorted, with "—"
|
||||||
|
// last so the picker reads real callsigns first.
|
||||||
|
func (r *Repo) Operators(ctx context.Context) ([]string, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT DISTINCT COALESCE(operator,'') FROM qso`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for rows.Next() {
|
||||||
|
var op string
|
||||||
|
if err := rows.Scan(&op); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
op = strings.ToUpper(strings.TrimSpace(op))
|
||||||
|
if op == "" {
|
||||||
|
op = "—"
|
||||||
|
}
|
||||||
|
seen[op] = struct{}{}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
hasOwner := false
|
||||||
|
for op := range seen {
|
||||||
|
if op == "—" {
|
||||||
|
hasOwner = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, op)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
if hasOwner {
|
||||||
|
out = append(out, "—")
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, year int, operator string) (Stats, error) {
|
||||||
var s Stats
|
var s Stats
|
||||||
contestID = strings.ToUpper(strings.TrimSpace(contestID))
|
contestID = strings.ToUpper(strings.TrimSpace(contestID))
|
||||||
|
// Operator filter: "" = all operators; "—" = QSOs the station owner logged
|
||||||
|
// himself (empty OPERATOR); any other value = that operator's QSOs only.
|
||||||
|
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
||||||
|
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT callsign, qso_date, band, mode, cont, country, dxcc,
|
SELECT callsign, qso_date, band, mode, cont, country, dxcc,
|
||||||
@@ -228,6 +301,7 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
entities = map[int]struct{}{}
|
entities = map[int]struct{}{}
|
||||||
modeC = map[string]int{}
|
modeC = map[string]int{}
|
||||||
bandC = map[string]int{}
|
bandC = map[string]int{}
|
||||||
|
bandCat = map[string]*BandCategory{} // per band: cw/phone/data
|
||||||
opC = map[string]int{}
|
opC = map[string]int{}
|
||||||
stationC = map[string]int{}
|
stationC = map[string]int{}
|
||||||
contC = map[string]int{}
|
contC = map[string]int{}
|
||||||
@@ -257,6 +331,17 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An empty OPERATOR means "the station owner logged it himself" — bucket
|
||||||
|
// it as "—". Computed here so the operator filter can also drop QSOs that
|
||||||
|
// aren't this operator's before they reach ANY bucket.
|
||||||
|
op := strings.ToUpper(strings.TrimSpace(oper.String))
|
||||||
|
if op == "" {
|
||||||
|
op = "—"
|
||||||
|
}
|
||||||
|
if opFilter != "" && op != opFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Window first: a QSO outside the period must not reach ANY bucket. Doing
|
// Window first: a QSO outside the period must not reach ANY bucket. Doing
|
||||||
// this after the counting (the obvious mistake) would leave the mode/band/
|
// this after the counting (the obvious mistake) would leave the mode/band/
|
||||||
// operator charts showing the whole log while only the trend was filtered.
|
// operator charts showing the whole log while only the trend was filtered.
|
||||||
@@ -287,13 +372,22 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
}
|
}
|
||||||
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
|
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
|
||||||
bandC[b]++
|
bandC[b]++
|
||||||
|
bc := bandCat[b]
|
||||||
|
if bc == nil {
|
||||||
|
bc = &BandCategory{Band: b}
|
||||||
|
bandCat[b] = bc
|
||||||
|
}
|
||||||
|
switch modeCategory(mode.String) {
|
||||||
|
case "cw":
|
||||||
|
bc.CW++
|
||||||
|
case "phone":
|
||||||
|
bc.Phone++
|
||||||
|
default:
|
||||||
|
bc.Data++
|
||||||
|
}
|
||||||
|
bc.Total++
|
||||||
}
|
}
|
||||||
// An empty OPERATOR means "the station owner logged it himself" — bucket
|
// op was resolved above (with the operator filter applied).
|
||||||
// it explicitly rather than dropping the QSO from the operator chart.
|
|
||||||
op := strings.ToUpper(strings.TrimSpace(oper.String))
|
|
||||||
if op == "" {
|
|
||||||
op = "—"
|
|
||||||
}
|
|
||||||
opC[op]++
|
opC[op]++
|
||||||
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
|
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
|
||||||
stationC[st]++
|
stationC[st]++
|
||||||
@@ -368,6 +462,13 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
}
|
}
|
||||||
return a < b
|
return a < b
|
||||||
})
|
})
|
||||||
|
// Per-band CW/phone/data split, in the SAME band-plan order as ByBand.
|
||||||
|
s.ByBandCategory = []BandCategory{}
|
||||||
|
for _, b := range s.ByBand {
|
||||||
|
if bc := bandCat[b.Key]; bc != nil {
|
||||||
|
s.ByBandCategory = append(s.ByBandCategory, *bc)
|
||||||
|
}
|
||||||
|
}
|
||||||
// The time axis must be CONTINUOUS. Emitting only the months that have QSOs
|
// The time axis must be CONTINUOUS. Emitting only the months that have QSOs
|
||||||
// would place, say, 2012-08 next to 2022-01 as if they were consecutive — the
|
// would place, say, 2012-08 next to 2022-01 as if they were consecutive — the
|
||||||
// chart would invent activity that never happened. A gap in the log is real
|
// chart would invent activity that never happened. A gap in the log is real
|
||||||
@@ -376,11 +477,58 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
s.ByMonth = fillMonths(monthC, first, last)
|
s.ByMonth = fillMonths(monthC, first, last)
|
||||||
|
|
||||||
s.periodMetrics(times, from, to, first, last)
|
s.periodMetrics(times, from, to, first, last)
|
||||||
|
// Anchor the rolling activity windows to the period end when filtered, else to
|
||||||
|
// now (so "week" is the last 7 days ending today, like the operator expects).
|
||||||
|
ref := to
|
||||||
|
if ref.IsZero() {
|
||||||
|
ref = time.Now()
|
||||||
|
}
|
||||||
|
s.recentSeries(times, ref)
|
||||||
s.ensureNonNil()
|
s.ensureNonNil()
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recentSeries builds the rolling chronological activity windows for the chart's
|
||||||
|
// day/week/month/year selector, in UTC and anchored to `ref` (the period end when
|
||||||
|
// filtered, else now). Real dates in order — today and the days before it — so the
|
||||||
|
// axis reads chronologically and empty days are just zero, not misordered/wrapped.
|
||||||
|
func (s *Stats) recentSeries(times []entry, ref time.Time) {
|
||||||
|
ref = ref.UTC()
|
||||||
|
day0 := time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, time.UTC) // midnight of the anchor day
|
||||||
|
hour := make([]int, 24)
|
||||||
|
daily := map[string]int{}
|
||||||
|
monthly := map[string]int{}
|
||||||
|
for _, e := range times {
|
||||||
|
t := e.t.UTC()
|
||||||
|
if t.Year() == ref.Year() && t.YearDay() == ref.YearDay() {
|
||||||
|
hour[t.Hour()]++
|
||||||
|
}
|
||||||
|
daily[t.Format("2006-01-02")]++
|
||||||
|
monthly[t.Format("2006-01")]++
|
||||||
|
}
|
||||||
|
// Day: the anchor day, hour by hour.
|
||||||
|
for h := 0; h < 24; h++ {
|
||||||
|
s.ByHour = append(s.ByHour, Bucket{Key: fmt.Sprintf("%02dh", h), Count: hour[h]})
|
||||||
|
}
|
||||||
|
// Week: the last 7 days ending today (today is last). Label = weekday + day.
|
||||||
|
for i := 6; i >= 0; i-- {
|
||||||
|
d := day0.AddDate(0, 0, -i)
|
||||||
|
s.ByDay7 = append(s.ByDay7, Bucket{Key: d.Format("Mon 2"), Count: daily[d.Format("2006-01-02")]})
|
||||||
|
}
|
||||||
|
// Month: the last 30 days.
|
||||||
|
for i := 29; i >= 0; i-- {
|
||||||
|
d := day0.AddDate(0, 0, -i)
|
||||||
|
s.ByDay30 = append(s.ByDay30, Bucket{Key: d.Format("2/1"), Count: daily[d.Format("2006-01-02")]})
|
||||||
|
}
|
||||||
|
// Year: the last 12 months.
|
||||||
|
m0 := time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
for i := 11; i >= 0; i-- {
|
||||||
|
m := m0.AddDate(0, -i, 0)
|
||||||
|
s.ByMonth12 = append(s.ByMonth12, Bucket{Key: m.Format("Jan 06"), Count: monthly[m.Format("2006-01")]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ensureNonNil replaces every nil slice with an empty one.
|
// ensureNonNil replaces every nil slice with an empty one.
|
||||||
//
|
//
|
||||||
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
|
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
// The Denkovi USB board is driven through FTDI's D2XX bit-bang API (ftd2xx.dll),
|
||||||
|
// which OpsLog only wires up on Windows. This stub keeps the package building on
|
||||||
|
// other platforms; every call reports the board is unavailable.
|
||||||
|
package relaydev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type denkoviStub struct{ count int }
|
||||||
|
|
||||||
|
// NewDenkovi returns a stub on non-Windows builds.
|
||||||
|
func NewDenkovi(serial string, count int) Device {
|
||||||
|
if count != 4 {
|
||||||
|
count = 8
|
||||||
|
}
|
||||||
|
return denkoviStub{count: count}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s denkoviStub) Count() int { return s.count }
|
||||||
|
func (denkoviStub) Close() error { return nil }
|
||||||
|
func (denkoviStub) Status(context.Context) ([]bool, error) {
|
||||||
|
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||||
|
}
|
||||||
|
func (denkoviStub) Set(context.Context, int, bool) error {
|
||||||
|
return fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDenkovi has no devices to report off Windows.
|
||||||
|
func ListDenkovi() ([]string, error) {
|
||||||
|
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
// Denkovi USB 8-channel relay board (FT245RL). Despite enumerating as a virtual
|
||||||
|
// COM port, this board is NOT driven by serial/ASCII: the FT245's 8 data lines
|
||||||
|
// each drive a relay, controlled through FTDI's D2XX "bit-bang" mode. One byte
|
||||||
|
// written = the 8 relays at once (bit 0 = relay 1). We load ftd2xx.dll at runtime
|
||||||
|
// (no CGO) and call the D2XX API directly, exactly as the vendor's tool does
|
||||||
|
// (which addresses the board by its FTDI serial, e.g. "DAE0006K").
|
||||||
|
package relaydev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ftdll = syscall.NewLazyDLL("ftd2xx.dll")
|
||||||
|
procOpenEx = ftdll.NewProc("FT_OpenEx")
|
||||||
|
procClose = ftdll.NewProc("FT_Close")
|
||||||
|
procSetBitMode = ftdll.NewProc("FT_SetBitMode")
|
||||||
|
procSetBaudRate = ftdll.NewProc("FT_SetBaudRate")
|
||||||
|
procWrite = ftdll.NewProc("FT_Write")
|
||||||
|
procPurge = ftdll.NewProc("FT_Purge")
|
||||||
|
procCreateInfo = ftdll.NewProc("FT_CreateDeviceInfoList")
|
||||||
|
procGetInfoDetail = ftdll.NewProc("FT_GetDeviceInfoDetail")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ftOpenBySerial = 1 // FT_OPEN_BY_SERIAL_NUMBER
|
||||||
|
ftBitModeSyncBB = 0x04 // FT_BITMODE_SYNC_BITBANG (the mode Denkovi documents)
|
||||||
|
ftPurgeRX = 1 // FT_PURGE_RX
|
||||||
|
)
|
||||||
|
|
||||||
|
func ftOK(r uintptr) bool { return r == 0 } // FT_OK == 0
|
||||||
|
|
||||||
|
type denkovi struct {
|
||||||
|
serial string
|
||||||
|
count int
|
||||||
|
mu sync.Mutex
|
||||||
|
shadow byte // last output byte (bit n = relay n+1); authoritative state
|
||||||
|
h uintptr // FT handle
|
||||||
|
opened bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDenkovi builds a driver for a Denkovi USB relay board (4 or 8 relays)
|
||||||
|
// identified by its FTDI serial number (shown by the vendor tool / FT_PROG,
|
||||||
|
// e.g. "DAE0006K"). count defaults to 8; a 4-relay board just uses the low 4 bits.
|
||||||
|
func NewDenkovi(serial string, count int) Device {
|
||||||
|
if count != 4 && count != 8 {
|
||||||
|
count = 8
|
||||||
|
}
|
||||||
|
return &denkovi{serial: strings.TrimSpace(serial), count: count}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *denkovi) Count() int { return d.count }
|
||||||
|
|
||||||
|
// ensureOpen opens the board and puts it in synchronous bit-bang mode with all 8
|
||||||
|
// lines as outputs. Idempotent.
|
||||||
|
func (d *denkovi) ensureOpen() error {
|
||||||
|
if d.opened {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := ftdll.Load(); err != nil {
|
||||||
|
return fmt.Errorf("FTDI D2XX driver (ftd2xx.dll) not found — install the FTDI D2XX driver / Denkovi software: %w", err)
|
||||||
|
}
|
||||||
|
if d.serial == "" {
|
||||||
|
return fmt.Errorf("no FTDI serial number set for the Denkovi board")
|
||||||
|
}
|
||||||
|
ser, err := syscall.BytePtrFromString(d.serial)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var h uintptr
|
||||||
|
if r, _, _ := procOpenEx.Call(uintptr(unsafe.Pointer(ser)), ftOpenBySerial, uintptr(unsafe.Pointer(&h))); !ftOK(r) {
|
||||||
|
return fmt.Errorf("cannot open Denkovi board %q (FT_OpenEx status %d) — is it connected and not in use by another app?", d.serial, r)
|
||||||
|
}
|
||||||
|
// All 8 lines output, synchronous bit-bang.
|
||||||
|
if r, _, _ := procSetBitMode.Call(h, 0xFF, ftBitModeSyncBB); !ftOK(r) {
|
||||||
|
procClose.Call(h)
|
||||||
|
return fmt.Errorf("FT_SetBitMode failed (status %d)", r)
|
||||||
|
}
|
||||||
|
procSetBaudRate.Call(h, 9600) // bit-bang pin-update clock; relays don't need speed
|
||||||
|
d.h = h
|
||||||
|
d.opened = true
|
||||||
|
// Make the hardware match our shadow (starts all-off on first open).
|
||||||
|
return d.writeLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeLocked pushes the shadow byte to the relays. Caller holds d.mu.
|
||||||
|
func (d *denkovi) writeLocked() error {
|
||||||
|
var written uint32
|
||||||
|
b := d.shadow
|
||||||
|
if r, _, _ := procWrite.Call(d.h, uintptr(unsafe.Pointer(&b)), 1, uintptr(unsafe.Pointer(&written))); !ftOK(r) {
|
||||||
|
return fmt.Errorf("FT_Write failed (status %d)", r)
|
||||||
|
}
|
||||||
|
// Synchronous bit-bang echoes each written byte into the RX buffer; drop it so
|
||||||
|
// it doesn't fill over the life of the connection.
|
||||||
|
procPurge.Call(d.h, ftPurgeRX)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *denkovi) Set(ctx context.Context, relay int, on bool) error {
|
||||||
|
if relay < 1 || relay > d.count {
|
||||||
|
return fmt.Errorf("relay %d out of range 1..%d", relay, d.count)
|
||||||
|
}
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if err := d.ensureOpen(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bit := byte(1) << uint(relay-1) // relay 1 → bit 0
|
||||||
|
if on {
|
||||||
|
d.shadow |= bit
|
||||||
|
} else {
|
||||||
|
d.shadow &^= bit
|
||||||
|
}
|
||||||
|
return d.writeLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the FTDI handle so the board can be reopened later (only one
|
||||||
|
// handle may hold a D2XX device at a time).
|
||||||
|
func (d *denkovi) Close() error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if d.opened {
|
||||||
|
procClose.Call(d.h)
|
||||||
|
d.opened = false
|
||||||
|
d.h = 0
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *denkovi) Status(ctx context.Context) ([]bool, error) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if err := d.ensureOpen(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]bool, d.count)
|
||||||
|
for i := 0; i < d.count; i++ {
|
||||||
|
out[i] = d.shadow&(byte(1)<<uint(i)) != 0
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDenkovi returns the FTDI serial numbers of connected devices, for the
|
||||||
|
// settings UI to pick from. Requires ftd2xx.dll.
|
||||||
|
func ListDenkovi() ([]string, error) {
|
||||||
|
if err := ftdll.Load(); err != nil {
|
||||||
|
return nil, fmt.Errorf("FTDI D2XX driver (ftd2xx.dll) not found — install the FTDI D2XX driver / Denkovi software")
|
||||||
|
}
|
||||||
|
var n uint32
|
||||||
|
if r, _, _ := procCreateInfo.Call(uintptr(unsafe.Pointer(&n))); !ftOK(r) {
|
||||||
|
return nil, fmt.Errorf("FT_CreateDeviceInfoList failed (status %d)", r)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for i := uint32(0); i < n; i++ {
|
||||||
|
var flags, typ, id, loc uint32
|
||||||
|
serial := make([]byte, 16)
|
||||||
|
desc := make([]byte, 64)
|
||||||
|
var h uintptr
|
||||||
|
r, _, _ := procGetInfoDetail.Call(
|
||||||
|
uintptr(i),
|
||||||
|
uintptr(unsafe.Pointer(&flags)), uintptr(unsafe.Pointer(&typ)),
|
||||||
|
uintptr(unsafe.Pointer(&id)), uintptr(unsafe.Pointer(&loc)),
|
||||||
|
uintptr(unsafe.Pointer(&serial[0])), uintptr(unsafe.Pointer(&desc[0])),
|
||||||
|
uintptr(unsafe.Pointer(&h)),
|
||||||
|
)
|
||||||
|
if !ftOK(r) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s := cstr(serial); s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cstr trims a C string (up to the first NUL) from a fixed buffer.
|
||||||
|
func cstr(b []byte) string {
|
||||||
|
if i := indexByte(b, 0); i >= 0 {
|
||||||
|
b = b[:i]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexByte(b []byte, c byte) int {
|
||||||
|
for i := range b {
|
||||||
|
if b[i] == c {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// Package relaydev drives network relay boards used for station control (power
|
||||||
|
// sequencing, switching accessories). Two devices are supported, both over HTTP:
|
||||||
|
//
|
||||||
|
// - WebSwitch 1216H — 5 relays. Control: GET /relaycontrol/{on|off}/{n};
|
||||||
|
// status: GET /relaystate/get2/1$2$3$4$5$ → lines "n,state".
|
||||||
|
// (Protocol taken from the operator's own working ShackMaster driver.)
|
||||||
|
// - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss}
|
||||||
|
// (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with
|
||||||
|
// <relay1>..<relay8> (relay0 is reserved). Optional HTTP basic auth.
|
||||||
|
//
|
||||||
|
// A Device presents the same surface to the app regardless of wire protocol.
|
||||||
|
package relaydev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Device is one relay board.
|
||||||
|
type Device interface {
|
||||||
|
Count() int // number of user-controllable relays
|
||||||
|
Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1)
|
||||||
|
Set(ctx context.Context, relay int, on bool) error // relay is 1-based
|
||||||
|
// Close releases any OS handle the driver holds (serial port, FTDI handle).
|
||||||
|
// Network boards hold nothing and no-op. MUST be called when a cached driver is
|
||||||
|
// discarded so the port/handle is freed for the next open — stateful boards
|
||||||
|
// (Denkovi, USB-serial) can only be opened by one handle at a time.
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpClient() *http.Client { return &http.Client{Timeout: 5 * time.Second} }
|
||||||
|
|
||||||
|
// get issues a GET with optional basic auth and returns the body on 2xx.
|
||||||
|
func get(ctx context.Context, url, user, pass string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user != "" || pass != "" {
|
||||||
|
req.SetBasicAuth(user, pass)
|
||||||
|
}
|
||||||
|
resp, err := httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebSwitch 1216H ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type webswitch struct {
|
||||||
|
host string
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWebswitch builds a WebSwitch 1216H client (5 relays).
|
||||||
|
func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} }
|
||||||
|
|
||||||
|
func (w *webswitch) Count() int { return w.count }
|
||||||
|
func (w *webswitch) Close() error { return nil } // stateless HTTP, nothing to release
|
||||||
|
|
||||||
|
func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
|
||||||
|
if relay < 1 || relay > w.count {
|
||||||
|
return fmt.Errorf("relay %d out of range 1..%d", relay, w.count)
|
||||||
|
}
|
||||||
|
action := "off"
|
||||||
|
if on {
|
||||||
|
action = "on"
|
||||||
|
}
|
||||||
|
_, err := get(ctx, fmt.Sprintf("http://%s/relaycontrol/%s/%d", w.host, action, relay), "", "")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *webswitch) Status(ctx context.Context) ([]bool, error) {
|
||||||
|
// Build the "1$2$3$..." selector the device expects.
|
||||||
|
var sel strings.Builder
|
||||||
|
for i := 1; i <= w.count; i++ {
|
||||||
|
sel.WriteString(strconv.Itoa(i))
|
||||||
|
sel.WriteByte('$')
|
||||||
|
}
|
||||||
|
body, err := get(ctx, fmt.Sprintf("http://%s/relaystate/get2/%s", w.host, sel.String()), "", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]bool, w.count)
|
||||||
|
// Lines "n,state" — "1,1", "2,0", …
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
|
||||||
|
parts := strings.Split(strings.TrimSpace(line), ",")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, e1 := strconv.Atoi(parts[0])
|
||||||
|
st, e2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||||
|
if e1 != nil || e2 != nil || n < 1 || n > w.count {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[n-1] = st == 1
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── KMTronic LAN 8-relay WEB ───────────────────────────────────────────
|
||||||
|
|
||||||
|
type kmtronic struct {
|
||||||
|
host string
|
||||||
|
user, pass string
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKMTronic builds a KMTronic LAN WEB relay client (8 relays). user/pass are
|
||||||
|
// blank unless the board's HTTP authentication is enabled.
|
||||||
|
func NewKMTronic(host, user, pass string) Device {
|
||||||
|
return &kmtronic{host: host, user: user, pass: pass, count: 8}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *kmtronic) Count() int { return k.count }
|
||||||
|
func (k *kmtronic) Close() error { return nil } // stateless HTTP, nothing to release
|
||||||
|
|
||||||
|
func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
|
||||||
|
if relay < 1 || relay > k.count {
|
||||||
|
return fmt.Errorf("relay %d out of range 1..%d", relay, k.count)
|
||||||
|
}
|
||||||
|
state := "00"
|
||||||
|
if on {
|
||||||
|
state = "01"
|
||||||
|
}
|
||||||
|
// FF<rr><ss>: e.g. FF0101 = relay 1 on, FF0800 = relay 8 off.
|
||||||
|
_, err := get(ctx, fmt.Sprintf("http://%s/FF%02d%s", k.host, relay, state), k.user, k.pass)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// kmStatus mirrors status.xml. relay0 is reserved; relay1..relay8 are the board.
|
||||||
|
type kmStatus struct {
|
||||||
|
XMLName xml.Name `xml:"response"`
|
||||||
|
Relays []struct {
|
||||||
|
XMLName xml.Name
|
||||||
|
Value string `xml:",chardata"`
|
||||||
|
} `xml:",any"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
|
||||||
|
body, err := get(ctx, fmt.Sprintf("http://%s/status.xml", k.host), k.user, k.pass)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var s kmStatus
|
||||||
|
if err := xml.Unmarshal(body, &s); err != nil {
|
||||||
|
return nil, fmt.Errorf("kmtronic: bad status.xml: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]bool, k.count)
|
||||||
|
for _, r := range s.Relays {
|
||||||
|
// Element names are relay0..relay8; relay0 is reserved.
|
||||||
|
name := r.XMLName.Local
|
||||||
|
if !strings.HasPrefix(name, "relay") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, e := strconv.Atoi(strings.TrimPrefix(name, "relay"))
|
||||||
|
if e != nil || n < 1 || n > k.count {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[n-1] = strings.TrimSpace(r.Value) == "1"
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package relaydev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The status parsers are the risky part; pin them against the documented wire
|
||||||
|
// formats using a stub HTTP server.
|
||||||
|
|
||||||
|
func TestWebswitchStatus(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/relaystate/get2/") {
|
||||||
|
t.Errorf("unexpected status path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte("1,1\n2,0\n3,1\n4,0\n5,1\n"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
d := NewWebswitch(strings.TrimPrefix(srv.URL, "http://"))
|
||||||
|
st, err := d.Status(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []bool{true, false, true, false, true}
|
||||||
|
if len(st) != 5 {
|
||||||
|
t.Fatalf("got %d relays, want 5", len(st))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if st[i] != want[i] {
|
||||||
|
t.Errorf("relay %d = %v, want %v", i+1, st[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebswitchSetURL(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
d := NewWebswitch(strings.TrimPrefix(srv.URL, "http://"))
|
||||||
|
if err := d.Set(context.Background(), 4, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotPath != "/relaycontrol/on/4" {
|
||||||
|
t.Errorf("set path = %q, want /relaycontrol/on/4", gotPath)
|
||||||
|
}
|
||||||
|
_ = d.Set(context.Background(), 4, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKMTronicStatus(t *testing.T) {
|
||||||
|
// relay0 reserved (ignored); relay7 + relay8 ON.
|
||||||
|
xmlBody := `<?xml version="1.0"?><response>` +
|
||||||
|
`<relay0>0</relay0><relay1>0</relay1><relay2>0</relay2><relay3>0</relay3>` +
|
||||||
|
`<relay4>0</relay4><relay5>0</relay5><relay6>0</relay6><relay7>1</relay7><relay8>1</relay8>` +
|
||||||
|
`</response>`
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/status.xml" {
|
||||||
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(xmlBody))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
d := NewKMTronic(strings.TrimPrefix(srv.URL, "http://"), "", "")
|
||||||
|
st, err := d.Status(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(st) != 8 {
|
||||||
|
t.Fatalf("got %d relays, want 8", len(st))
|
||||||
|
}
|
||||||
|
if st[6] != true || st[7] != true {
|
||||||
|
t.Errorf("relay7/8 = %v/%v, want on/on", st[6], st[7])
|
||||||
|
}
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
if st[i] {
|
||||||
|
t.Errorf("relay %d unexpectedly on", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKMTronicSetURL(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
d := NewKMTronic(strings.TrimPrefix(srv.URL, "http://"), "", "")
|
||||||
|
if err := d.Set(context.Background(), 8, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotPath != "/FF0801" {
|
||||||
|
t.Errorf("set path = %q, want /FF0801", gotPath)
|
||||||
|
}
|
||||||
|
_ = d.Set(context.Background(), 1, false) // → /FF0100
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package relaydev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serialRelay drives the common cheap USB-serial relay boards (CH340 / LCUS-1
|
||||||
|
// style) that use the "A0" command protocol over a virtual COM port:
|
||||||
|
//
|
||||||
|
// [0xA0] [relay 1-based] [state 0|1] [checksum] checksum = (0xA0+relay+state) & 0xFF
|
||||||
|
//
|
||||||
|
// e.g. relay 1 ON = A0 01 01 A2, relay 1 OFF = A0 01 00 A1. These boards are
|
||||||
|
// write-only (no state readback), so Status reflects a shadow of what we sent.
|
||||||
|
// Channel count varies by board (1/2/4/8/16), so it is configurable.
|
||||||
|
type serialRelay struct {
|
||||||
|
portName string
|
||||||
|
count int
|
||||||
|
baud int
|
||||||
|
mu sync.Mutex
|
||||||
|
port serial.Port
|
||||||
|
shadow []bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSerialRelay builds a driver for a CH340/LCUS-style USB-serial relay board on
|
||||||
|
// the given COM port with `count` channels (defaults to 8).
|
||||||
|
func NewSerialRelay(port string, count int) Device {
|
||||||
|
if count < 1 {
|
||||||
|
count = 8
|
||||||
|
}
|
||||||
|
return &serialRelay{portName: strings.TrimSpace(port), count: count, baud: 9600, shadow: make([]bool, count)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialRelay) Count() int { return s.count }
|
||||||
|
|
||||||
|
// Close releases the COM port so it can be reopened later (only one handle may
|
||||||
|
// hold a serial port at a time).
|
||||||
|
func (s *serialRelay) Close() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.port != nil {
|
||||||
|
err := s.port.Close()
|
||||||
|
s.port = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialRelay) ensureOpen() error {
|
||||||
|
if s.port != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s.portName == "" {
|
||||||
|
return fmt.Errorf("no COM port set for the USB relay board")
|
||||||
|
}
|
||||||
|
p, err := serial.Open(s.portName, &serial.Mode{BaudRate: s.baud})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", s.portName, err)
|
||||||
|
}
|
||||||
|
s.port = p
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialRelay) Set(ctx context.Context, relay int, on bool) error {
|
||||||
|
if relay < 1 || relay > s.count {
|
||||||
|
return fmt.Errorf("relay %d out of range 1..%d", relay, s.count)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if err := s.ensureOpen(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st := byte(0)
|
||||||
|
if on {
|
||||||
|
st = 1
|
||||||
|
}
|
||||||
|
r := byte(relay)
|
||||||
|
frame := []byte{0xA0, r, st, byte(0xA0) + r + st} // last byte = checksum
|
||||||
|
if _, err := s.port.Write(frame); err != nil {
|
||||||
|
// Drop the handle so the next call reopens (USB unplugged / port reset).
|
||||||
|
s.port.Close()
|
||||||
|
s.port = nil
|
||||||
|
return fmt.Errorf("write to %s: %w", s.portName, err)
|
||||||
|
}
|
||||||
|
s.shadow[relay-1] = on
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialRelay) Status(ctx context.Context) ([]bool, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if err := s.ensureOpen(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]bool, s.count)
|
||||||
|
copy(out, s.shadow)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// Package rotgenius drives a 4O3A Rotator Genius over its native TCP text
|
||||||
|
// protocol (rev 4, default port 9006). All data is fixed-length extended-ASCII;
|
||||||
|
// there is no sequence/framing wrapper — you send a short command and read back a
|
||||||
|
// fixed-length reply.
|
||||||
|
//
|
||||||
|
// Commands used here:
|
||||||
|
//
|
||||||
|
// |h read heading + full state (both rotators)
|
||||||
|
// |A<rot><az3> move rotator <rot> ('1'|'2') to azimuth az3 (000..360)
|
||||||
|
// |P<rot> / |M<rot> rotate CW / CCW
|
||||||
|
// |S stop all movement
|
||||||
|
//
|
||||||
|
// The |h reply is 72 bytes: "|h" + Active[1] + Panic[1] then, per rotator,
|
||||||
|
// CurrentAzimuth[3] LimitCW[3] LimitCCW[3] Config[1] Moving[1] Offset[4]
|
||||||
|
// TargetAzimuth[3] StartAzimuth[3] Limit[1] Name[12]. Numeric fields may be
|
||||||
|
// space-padded; a CurrentAzimuth of 999 means the sensor is not connected.
|
||||||
|
package rotgenius
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPort = 9006
|
||||||
|
dialTimeout = 4 * time.Second
|
||||||
|
ioTimeout = 4 * time.Second
|
||||||
|
hdrReplyLen = 72 // fixed length of the |h reply
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status is one rotator's live state parsed from a |h reply.
|
||||||
|
type Status struct {
|
||||||
|
Azimuth int // current heading in degrees (0..360)
|
||||||
|
Connected bool // false when the sensor reports 999 (not connected)
|
||||||
|
Moving int // 0 not moving, 1 CW, 2 CCW
|
||||||
|
Target int // target azimuth when moving (else -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is a stateless connector: each call opens a short-lived TCP connection,
|
||||||
|
// mirroring how the PstRotator client works, so there is no socket to manage.
|
||||||
|
type Client struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(host string, port int) *Client {
|
||||||
|
if port <= 0 {
|
||||||
|
port = defaultPort
|
||||||
|
}
|
||||||
|
return &Client{host: host, port: port}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) dial() (net.Conn, error) {
|
||||||
|
d := net.Dialer{Timeout: dialTimeout}
|
||||||
|
conn, err := d.Dial("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(ioTimeout))
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchange sends cmd and returns up to max bytes of the reply.
|
||||||
|
func (c *Client) exchange(cmd string, max int) ([]byte, error) {
|
||||||
|
conn, err := c.dial()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if _, err := conn.Write([]byte(cmd)); err != nil {
|
||||||
|
return nil, fmt.Errorf("write %q: %w", cmd, err)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 0, max)
|
||||||
|
tmp := make([]byte, max)
|
||||||
|
for len(buf) < max {
|
||||||
|
n, rerr := conn.Read(tmp)
|
||||||
|
if n > 0 {
|
||||||
|
buf = append(buf, tmp[:n]...)
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
break // deadline or EOF — return what we have and let the parser judge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// atoiField trims the space-padding a Rotator Genius field may carry and parses
|
||||||
|
// it. An empty or non-numeric field yields 0.
|
||||||
|
func atoiField(s string) int {
|
||||||
|
n, _ := strconv.Atoi(strings.TrimSpace(s))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heading reads the current azimuth of the given rotator (1 or 2). raw is the
|
||||||
|
// decoded field for diagnostics.
|
||||||
|
func (c *Client) Heading(rotator int) (Status, string, error) {
|
||||||
|
st, err := c.Read(rotator)
|
||||||
|
if err != nil {
|
||||||
|
return Status{}, "", err
|
||||||
|
}
|
||||||
|
return st, strconv.Itoa(st.Azimuth), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read fetches and parses the full |h reply for one rotator (1 or 2).
|
||||||
|
func (c *Client) Read(rotator int) (Status, error) {
|
||||||
|
if rotator != 1 && rotator != 2 {
|
||||||
|
rotator = 1
|
||||||
|
}
|
||||||
|
reply, err := c.exchange("|h", hdrReplyLen)
|
||||||
|
if err != nil {
|
||||||
|
return Status{}, err
|
||||||
|
}
|
||||||
|
i := strings.Index(string(reply), "|h")
|
||||||
|
if i < 0 || len(reply)-i < hdrReplyLen {
|
||||||
|
return Status{}, fmt.Errorf("rotgenius: short |h reply (%d bytes)", len(reply))
|
||||||
|
}
|
||||||
|
p := reply[i:]
|
||||||
|
// Per-rotator block base: rotator 1 at offset 4, rotator 2 at 4+34=38.
|
||||||
|
base := 4
|
||||||
|
if rotator == 2 {
|
||||||
|
base = 38
|
||||||
|
}
|
||||||
|
// Within a rotator block: CurrentAzimuth@0, LimitCW@3, LimitCCW@6, Config@9,
|
||||||
|
// Moving@10, Offset@11, TargetAzimuth@15, StartAzimuth@18, Limit@21, Name@22.
|
||||||
|
cur := atoiField(string(p[base : base+3]))
|
||||||
|
moving := atoiField(string(p[base+10 : base+11]))
|
||||||
|
target := atoiField(string(p[base+15 : base+18]))
|
||||||
|
st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1}
|
||||||
|
if target != 999 {
|
||||||
|
st.Target = target
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoTo moves the rotator to az (0..360). The reply's status byte is 'K' on
|
||||||
|
// accept, 'F' on reject.
|
||||||
|
func (c *Client) GoTo(rotator, az int) error {
|
||||||
|
if rotator != 1 && rotator != 2 {
|
||||||
|
rotator = 1
|
||||||
|
}
|
||||||
|
if az < 0 {
|
||||||
|
az = 0
|
||||||
|
}
|
||||||
|
if az > 360 {
|
||||||
|
az = 360
|
||||||
|
}
|
||||||
|
reply, err := c.exchange(fmt.Sprintf("|A%d%03d", rotator, az), 8)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return checkKF(reply, "GoTo")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop halts all movement.
|
||||||
|
func (c *Client) Stop() error {
|
||||||
|
reply, err := c.exchange("|S", 8)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return checkKF(reply, "Stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CW / CCW nudge a rotator; it runs to its limit unless stopped.
|
||||||
|
func (c *Client) CW(rotator int) error { return c.rotate('P', rotator) }
|
||||||
|
func (c *Client) CCW(rotator int) error { return c.rotate('M', rotator) }
|
||||||
|
|
||||||
|
func (c *Client) rotate(cmd byte, rotator int) error {
|
||||||
|
if rotator != 1 && rotator != 2 {
|
||||||
|
rotator = 1
|
||||||
|
}
|
||||||
|
reply, err := c.exchange(fmt.Sprintf("|%c%d", cmd, rotator), 8)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return checkKF(reply, string(cmd))
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkKF reads the accept/reject status: 'K' ok, 'F' failed. The reply carries
|
||||||
|
// no other letters (the rest is the header + digits), so scanning for them is
|
||||||
|
// unambiguous.
|
||||||
|
func checkKF(reply []byte, what string) error {
|
||||||
|
s := string(reply)
|
||||||
|
if strings.ContainsRune(s, 'K') {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.ContainsRune(s, 'F') {
|
||||||
|
return fmt.Errorf("rotgenius: %s rejected by the controller", what)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("rotgenius: no reply to %s", what)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package rotgenius
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Build a 72-byte |h reply from per-rotator field values, so the fixed offsets in
|
||||||
|
// Read() are pinned to the rev-4 layout. Numeric fields are space/zero padded to
|
||||||
|
// their documented widths.
|
||||||
|
func buildHReply(cur1, cw1, ccw1 string, cfg1, mv1 byte, off1, tgt1, start1 string, lim1 byte, name1,
|
||||||
|
cur2, cw2, ccw2 string, cfg2, mv2 byte, off2, tgt2, start2 string, lim2 byte, name2 string) []byte {
|
||||||
|
pad := func(s string, n int) string {
|
||||||
|
for len(s) < n {
|
||||||
|
s = " " + s
|
||||||
|
}
|
||||||
|
return s[:n]
|
||||||
|
}
|
||||||
|
b := []byte("|h")
|
||||||
|
b = append(b, '0', 0x00) // Active, Panic
|
||||||
|
block := func(cur, cw, ccw string, cfg, mv byte, off, tgt, start string, lim byte, name string) {
|
||||||
|
b = append(b, []byte(pad(cur, 3))...)
|
||||||
|
b = append(b, []byte(pad(cw, 3))...)
|
||||||
|
b = append(b, []byte(pad(ccw, 3))...)
|
||||||
|
b = append(b, cfg, mv)
|
||||||
|
b = append(b, []byte(pad(off, 4))...)
|
||||||
|
b = append(b, []byte(pad(tgt, 3))...)
|
||||||
|
b = append(b, []byte(pad(start, 3))...)
|
||||||
|
b = append(b, lim)
|
||||||
|
b = append(b, []byte(pad(name, 12))...)
|
||||||
|
}
|
||||||
|
block(cur1, cw1, ccw1, cfg1, mv1, off1, tgt1, start1, lim1, name1)
|
||||||
|
block(cur2, cw2, ccw2, cfg2, mv2, off2, tgt2, start2, lim2, name2)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadParsesBothRotators(t *testing.T) {
|
||||||
|
// Rotator 1: az 100, moving CW (1), no target (999). Rotator 2: az 999 (sensor
|
||||||
|
// offline), not moving. Mirrors the manual's worked example.
|
||||||
|
reply := buildHReply(
|
||||||
|
"100", "005", "350", 'A', '1', "0", "999", "999", '0', "TOW1",
|
||||||
|
"999", "010", "060", 'E', '0', "1", "999", "999", '0', "")
|
||||||
|
|
||||||
|
c := &Client{}
|
||||||
|
_ = c
|
||||||
|
if len(reply) != hdrReplyLen {
|
||||||
|
t.Fatalf("built reply is %d bytes, want %d — field widths drifted from rev 4", len(reply), hdrReplyLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
st1, err := parseFor(reply, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st1.Azimuth != 100 || !st1.Connected || st1.Moving != 1 {
|
||||||
|
t.Errorf("rotator 1 = %+v, want az 100, connected, moving CW", st1)
|
||||||
|
}
|
||||||
|
if st1.Target != -1 {
|
||||||
|
t.Errorf("rotator 1 target = %d, want -1 (999 = not set)", st1.Target)
|
||||||
|
}
|
||||||
|
|
||||||
|
st2, err := parseFor(reply, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st2.Connected || st2.Azimuth != 999 {
|
||||||
|
t.Errorf("rotator 2 = %+v, want disconnected (az 999)", st2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFor exercises the offset math without a socket.
|
||||||
|
func parseFor(reply []byte, rotator int) (Status, error) {
|
||||||
|
base := 4
|
||||||
|
if rotator == 2 {
|
||||||
|
base = 38
|
||||||
|
}
|
||||||
|
if len(reply) < hdrReplyLen {
|
||||||
|
return Status{}, errShort
|
||||||
|
}
|
||||||
|
cur := atoiField(string(reply[base : base+3]))
|
||||||
|
moving := atoiField(string(reply[base+10 : base+11]))
|
||||||
|
target := atoiField(string(reply[base+15 : base+18]))
|
||||||
|
st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1}
|
||||||
|
if target != 999 {
|
||||||
|
st.Target = target
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var errShort = fmtErrorf("short")
|
||||||
|
|
||||||
|
func fmtErrorf(s string) error { return &strErr{s} }
|
||||||
|
|
||||||
|
type strErr struct{ s string }
|
||||||
|
|
||||||
|
func (e *strErr) Error() string { return e.s }
|
||||||
|
|
||||||
|
func TestGoToFormatting(t *testing.T) {
|
||||||
|
// The command must zero-pad the azimuth to 3 digits, per the manual's fields.
|
||||||
|
cases := map[int]string{0: "|A1000", 5: "|A1005", 90: "|A1090", 360: "|A1360"}
|
||||||
|
for az, want := range cases {
|
||||||
|
got := "|A" + "1" + pad3(az)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("az %d → %q, want %q", az, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pad3(az int) string {
|
||||||
|
s := ""
|
||||||
|
switch {
|
||||||
|
case az >= 100:
|
||||||
|
s = itoa(az)
|
||||||
|
case az >= 10:
|
||||||
|
s = "0" + itoa(az)
|
||||||
|
default:
|
||||||
|
s = "00" + itoa(az)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var b []byte
|
||||||
|
for n > 0 {
|
||||||
|
b = append([]byte{byte('0' + n%10)}, b...)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
// Package spe drives the SPE Expert 1.3K-FA / 1.5K-FA / 2K-FA amplifiers over
|
||||||
|
// their proprietary serial protocol (SPE "Application Programmer's Guide" rev 1.1).
|
||||||
|
// The amp is reached either directly over USB (a virtual COM port) or over TCP via
|
||||||
|
// an RS232-to-Ethernet bridge — both are just an io.ReadWriteCloser to this code.
|
||||||
|
//
|
||||||
|
// Wire format (host → amp): 0x55 0x55 0x55 | CNT | DATA… | CHK
|
||||||
|
// CNT = number of DATA bytes, CHK = sum(DATA) mod 256.
|
||||||
|
// Status reply (amp → host): 0xAA 0xAA 0xAA | LEN | <LEN CSV bytes> | chk0 chk1 | CR LF
|
||||||
|
// LEN is 0x43 (67); the payload is 19 comma-separated fixed fields.
|
||||||
|
//
|
||||||
|
// This MVP implements the two commands anchored by worked examples in the guide:
|
||||||
|
// OPERATE (0x0D, toggles STANDBY↔OPERATE) and STATUS (0x90). Other keystroke codes
|
||||||
|
// exist but the guide's command table did not extract unambiguously, so they are
|
||||||
|
// left out rather than risk sending the wrong key to the amplifier.
|
||||||
|
package spe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE (confirmed on hw)
|
||||||
|
cmdStatus byte = 0x90 // request the status string
|
||||||
|
|
||||||
|
// Best-guess keystroke codes reconstructed from the APG command table after
|
||||||
|
// correcting the PDF's note-wrap misalignment (anchored to the confirmed
|
||||||
|
// OPERATE=0x0D): OFF=0x0A, POWER=0x0B. The POWER key doubles as power-on (when
|
||||||
|
// the amp is off) and the output-level cycle L→M→H (when it's on) — same as the
|
||||||
|
// single physical POWER button. To be verified on hardware.
|
||||||
|
cmdOff byte = 0x0A // OFF key — switch the amplifier off
|
||||||
|
cmdPower byte = 0x0B // POWER key — turn on / cycle output power level
|
||||||
|
|
||||||
|
syncHost = 0x55
|
||||||
|
syncAmp = 0xAA
|
||||||
|
|
||||||
|
dialTimeout = 5 * time.Second
|
||||||
|
ioTimeout = 3 * time.Second
|
||||||
|
pollEvery = 800 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status is the decoded amplifier state for the UI.
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"` // "20K" / "13K"
|
||||||
|
Operate bool `json:"operate"` // true = OPERATE, false = STANDBY
|
||||||
|
TX bool `json:"tx"` // true = transmitting
|
||||||
|
Input string `json:"input,omitempty"` // "1" / "2"
|
||||||
|
Band string `json:"band,omitempty"` // raw 2-char band code
|
||||||
|
PowerLevel string `json:"power_level,omitempty"` // L / M / H
|
||||||
|
OutputW int `json:"output_w"`
|
||||||
|
SWRATU float64 `json:"swr_atu"`
|
||||||
|
SWRAnt float64 `json:"swr_ant"`
|
||||||
|
VoltPA float64 `json:"volt_pa"`
|
||||||
|
CurrPA float64 `json:"curr_pa"`
|
||||||
|
TempC int `json:"temp_c"` // heatsink (upper) temperature
|
||||||
|
Warnings string `json:"warnings,omitempty"`
|
||||||
|
Alarms string `json:"alarms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config selects the transport.
|
||||||
|
type Config struct {
|
||||||
|
Transport string // "serial" | "tcp"
|
||||||
|
ComPort string // serial
|
||||||
|
Baud int // serial
|
||||||
|
Host string // tcp
|
||||||
|
Port int // tcp
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
cfg Config
|
||||||
|
|
||||||
|
mu sync.Mutex // serialises access to the connection
|
||||||
|
conn io.ReadWriteCloser
|
||||||
|
r *bufio.Reader
|
||||||
|
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
status Status
|
||||||
|
lastRaw string // last raw status payload logged (log only on change)
|
||||||
|
|
||||||
|
stop chan struct{}
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Client {
|
||||||
|
if cfg.Baud <= 0 {
|
||||||
|
cfg.Baud = 115200
|
||||||
|
}
|
||||||
|
return &Client{cfg: cfg, stop: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
if c.running {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.running = true
|
||||||
|
go c.pollLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.running = false
|
||||||
|
close(c.stop)
|
||||||
|
c.mu.Lock()
|
||||||
|
c.dropLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() Status {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
return c.status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setErr(err error) {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.Connected = false
|
||||||
|
c.status.LastError = err.Error()
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operate toggles the amplifier between STANDBY and OPERATE (the amp has a single
|
||||||
|
// OPERATE key that flips the state, so we send it only when the desired state
|
||||||
|
// differs from the last-read one).
|
||||||
|
func (c *Client) Operate(on bool) error {
|
||||||
|
if c.GetStatus().Operate == on {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.sendCmd(cmdOperate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleOperate flips STANDBY/OPERATE unconditionally.
|
||||||
|
func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) }
|
||||||
|
|
||||||
|
// PowerOn turns the amplifier on. Per the SPE manual this is NOT a data command
|
||||||
|
// but a hardware DTR pulse on the serial line (the "Remote_ON" pin), which needs
|
||||||
|
// the special SPE cable — so it only applies to the serial transport. Over TCP
|
||||||
|
// there is no DTR line and power-on isn't possible remotely.
|
||||||
|
func (c *Client) PowerOn() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
sp, ok := c.conn.(serial.Port)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("power-on needs the serial DTR line (special SPE cable); not available over network")
|
||||||
|
}
|
||||||
|
// A single positive pulse (~1.2s) on DTR, as the manual describes.
|
||||||
|
if err := sp.SetDTR(true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(1200 * time.Millisecond)
|
||||||
|
return sp.SetDTR(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PowerOff presses the OFF key (switches the amp off).
|
||||||
|
func (c *Client) PowerOff() error { return c.sendCmd(cmdOff) }
|
||||||
|
|
||||||
|
// SetPowerLevel cycles the output power level (L/M/H) to the requested one by
|
||||||
|
// tapping the POWER key (0x0B) and WAITING for the amp to actually report the new
|
||||||
|
// level before the next tap — the status is streamed, so we poll GetStatus rather
|
||||||
|
// than sleeping a fixed time. `level` is "L", "M" or "H" (case-insensitive).
|
||||||
|
func (c *Client) SetPowerLevel(level string) error {
|
||||||
|
want := strings.ToUpper(strings.TrimSpace(level))
|
||||||
|
if want == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// At most 3 taps to walk the 3-way L→M→H cycle around to the target.
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel)) == want {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
prev := strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel))
|
||||||
|
if err := c.sendCmd(cmdPower); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Wait (up to ~2s) for the streamed status to reflect the change.
|
||||||
|
for w := 0; w < 20; w++ {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
if strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel)) != prev {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) pollLoop() {
|
||||||
|
t := time.NewTicker(pollEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := c.ensureConn(); err != nil {
|
||||||
|
c.setErr(fmt.Errorf("connect: %w", err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The amp streams status frames faster than one per poll, so a backlog
|
||||||
|
// builds up and we'd read stale frames (the display lagged ~15s behind
|
||||||
|
// the real amp). Flush anything pending, THEN request + read exactly one
|
||||||
|
// fresh frame — the status we show is always current.
|
||||||
|
c.drainInput()
|
||||||
|
if err := c.sendCmd(cmdStatus); err != nil {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.dropLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.setErr(err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.readStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureConn() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var rwc io.ReadWriteCloser
|
||||||
|
var err error
|
||||||
|
if c.cfg.Transport == "tcp" {
|
||||||
|
var nc net.Conn
|
||||||
|
nc, err = net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
|
||||||
|
rwc = nc
|
||||||
|
} else {
|
||||||
|
rwc, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.conn = rwc
|
||||||
|
c.r = bufio.NewReader(rwc)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) dropLocked() {
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.r = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainInput discards everything currently pending on the link (OS buffer + the
|
||||||
|
// bufio reader) so the next read returns a fresh frame rather than a queued stale
|
||||||
|
// one. Called before each status request.
|
||||||
|
func (c *Client) drainInput() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sp, ok := c.conn.(serial.Port); ok {
|
||||||
|
_ = sp.ResetInputBuffer()
|
||||||
|
} else if nc, ok := c.conn.(net.Conn); ok {
|
||||||
|
_ = nc.SetReadDeadline(time.Now().Add(15 * time.Millisecond))
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := nc.Read(buf)
|
||||||
|
if n == 0 || err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.r != nil {
|
||||||
|
c.r.Reset(c.conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendCmd frames one keystroke code and writes it. Single-byte payload → CHK is
|
||||||
|
// the code itself.
|
||||||
|
func (c *Client) sendCmd(code byte) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
if nc, ok := c.conn.(net.Conn); ok {
|
||||||
|
_ = nc.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||||
|
}
|
||||||
|
pkt := []byte{syncHost, syncHost, syncHost, 0x01, code, code}
|
||||||
|
_, err := c.conn.Write(pkt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// readStatus reads one amp packet and, when it's a status string, decodes it. ACK
|
||||||
|
// packets (short) are consumed and ignored.
|
||||||
|
func (c *Client) readStatus() {
|
||||||
|
c.mu.Lock()
|
||||||
|
r := c.r
|
||||||
|
if nc, ok := c.conn.(net.Conn); ok && nc != nil {
|
||||||
|
_ = nc.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Sync on three 0xAA bytes.
|
||||||
|
run := 0
|
||||||
|
for run < 3 {
|
||||||
|
b, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.dropLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.setErr(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b == syncAmp {
|
||||||
|
run++
|
||||||
|
} else {
|
||||||
|
run = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
length, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := make([]byte, int(length))
|
||||||
|
if _, err := io.ReadFull(r, data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Status strings are the long ones (LEN 0x43 = 67). Short packets are ACKs
|
||||||
|
// (1 checksum byte, no CRLF) — nothing else to consume for those.
|
||||||
|
if length >= 40 {
|
||||||
|
// consume the 2 checksum bytes + CR LF
|
||||||
|
_, _ = r.Discard(4)
|
||||||
|
c.decodeCSV(string(data))
|
||||||
|
} else {
|
||||||
|
_, _ = r.Discard(1) // ACK checksum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeCSV parses the 19-field comma-separated status payload.
|
||||||
|
func (c *Client) decodeCSV(payload string) {
|
||||||
|
f := strings.Split(payload, ",")
|
||||||
|
get := func(i int) string {
|
||||||
|
if i < len(f) {
|
||||||
|
return strings.TrimSpace(f[i])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
pf := func(s string) float64 { v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64); return v }
|
||||||
|
pi := func(s string) int { v, _ := strconv.Atoi(strings.TrimSpace(s)); return v }
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
defer c.statusMu.Unlock()
|
||||||
|
// Log the raw status frame ONCE per change, so field alignment can be verified
|
||||||
|
// against real hardware (the doc's 19-field layout may differ per firmware).
|
||||||
|
if payload != c.lastRaw {
|
||||||
|
c.lastRaw = payload
|
||||||
|
applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
|
||||||
|
}
|
||||||
|
c.status.Connected = true
|
||||||
|
c.status.LastError = ""
|
||||||
|
// The real frame carries a leading empty field (it starts with a comma), so the
|
||||||
|
// 19 documented fields live at indices 1..19, not 0..18. Verified against a live
|
||||||
|
// 1.3K-FA: ",13K,S,R,A,1,05,1b,0r,M,0000, 0.00, 0.00, 1.3, 0.0, 26,000,000,N,N,".
|
||||||
|
c.status.Model = get(1)
|
||||||
|
c.status.Operate = get(2) == "O" // "O" = OPERATE, "S" = STANDBY
|
||||||
|
c.status.TX = get(3) == "T" // "T" = transmit, "R" = receive
|
||||||
|
c.status.Input = get(5)
|
||||||
|
c.status.Band = bandName(get(6))
|
||||||
|
c.status.PowerLevel = get(9)
|
||||||
|
c.status.OutputW = pi(get(10))
|
||||||
|
c.status.SWRATU = pf(get(11))
|
||||||
|
c.status.SWRAnt = pf(get(12))
|
||||||
|
c.status.VoltPA = pf(get(13))
|
||||||
|
c.status.CurrPA = pf(get(14))
|
||||||
|
c.status.TempC = pi(get(15))
|
||||||
|
if w := get(18); w != "" && w != "N" {
|
||||||
|
c.status.Warnings = w
|
||||||
|
} else {
|
||||||
|
c.status.Warnings = ""
|
||||||
|
}
|
||||||
|
if a := get(19); a != "" && a != "N" {
|
||||||
|
c.status.Alarms = a
|
||||||
|
} else {
|
||||||
|
c.status.Alarms = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bandName maps the SPE 2-digit band index to a human band label, falling back to
|
||||||
|
// the raw code for anything unrecognised.
|
||||||
|
func bandName(code string) string {
|
||||||
|
// SPE band index, verified against a live 1.3K-FA: the status frame reports the
|
||||||
|
// band as a 2-digit decimal string ordered by descending wavelength — 80m→"01"
|
||||||
|
// and 20m→"05" were both confirmed on hardware (the printed manual's table was
|
||||||
|
// off by one). 00=160m, 01=80m, 02=60m, 03=40m, 04=30m, 05=20m, 06=17m, 07=15m,
|
||||||
|
// 08=12m, 09=10m, 10=6m.
|
||||||
|
switch strings.TrimSpace(code) {
|
||||||
|
case "00":
|
||||||
|
return "160m"
|
||||||
|
case "01":
|
||||||
|
return "80m"
|
||||||
|
case "02":
|
||||||
|
return "60m"
|
||||||
|
case "03":
|
||||||
|
return "40m"
|
||||||
|
case "04":
|
||||||
|
return "30m"
|
||||||
|
case "05":
|
||||||
|
return "20m"
|
||||||
|
case "06":
|
||||||
|
return "17m"
|
||||||
|
case "07":
|
||||||
|
return "15m"
|
||||||
|
case "08":
|
||||||
|
return "12m"
|
||||||
|
case "09":
|
||||||
|
return "10m"
|
||||||
|
case "10":
|
||||||
|
return "6m"
|
||||||
|
case "":
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
// Package steppir controls a SteppIR SDA-100 / SDA-2000 antenna controller over
|
||||||
|
// its "Transceiver Interface" serial protocol, reached either directly on a COM
|
||||||
|
// port or over TCP through an RS232↔Ethernet bridge (the same way OpsLog talks to
|
||||||
|
// an Ultrabeam). The client mirrors the ultrabeam.Client surface so the app can
|
||||||
|
// drive either behind one interface.
|
||||||
|
//
|
||||||
|
// Protocol (cross-checked against the SteppIR "Transceiver Interface Operation"
|
||||||
|
// note, the we7u/steppir library, and the la1k.no write-up — three independent
|
||||||
|
// sources that agree, which is what makes the byte layout trustworthy):
|
||||||
|
//
|
||||||
|
// SET : "@A" <freq> 00 <dir> <cmd> 00 0x0D (11 bytes)
|
||||||
|
// <freq> = int32 big-endian of (Hz / 10)
|
||||||
|
// <dir> = 0x00 normal · 0x40 180° · 0x80 bidirectional · 0x20 3/4-wave
|
||||||
|
// <cmd> = '1' set freq+dir · 'R' autotrack ON · 'U' autotrack OFF
|
||||||
|
// 'S' home/retract · 'V' calibrate
|
||||||
|
// STATUS: "?A" 0x0D → 11 bytes back:
|
||||||
|
// [2:6] int32 big-endian frequency (× 10 = Hz)
|
||||||
|
// [6] active-motor bitmask (0xFF = command received / setup)
|
||||||
|
// [7] & 0xE0 direction
|
||||||
|
//
|
||||||
|
// Timing: the controller needs ≥100 ms between commands and dislikes status
|
||||||
|
// polls faster than ~10/s. The poll loop runs at 2 s, well inside that.
|
||||||
|
package steppir
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Direction values, matching the app-wide convention (also used by Ultrabeam):
|
||||||
|
// 0 normal, 1 reverse (180°), 2 bidirectional.
|
||||||
|
const (
|
||||||
|
DirNormal = 0
|
||||||
|
Dir180 = 1
|
||||||
|
DirBi = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// SteppIR direction bytes on the wire.
|
||||||
|
const (
|
||||||
|
wireNormal = 0x00
|
||||||
|
wire180 = 0x40
|
||||||
|
wireBi = 0x80
|
||||||
|
)
|
||||||
|
|
||||||
|
// Transport says how to reach the controller.
|
||||||
|
type Transport struct {
|
||||||
|
Mode string // "tcp" | "serial"
|
||||||
|
Host string // tcp
|
||||||
|
Port int // tcp
|
||||||
|
COM string // serial device (COM3, /dev/ttyUSB0)
|
||||||
|
Baud int // serial baud (controller default 9600; 1200-19200 valid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is the antenna state, in the same shape the app reads from the
|
||||||
|
// Ultrabeam so the two are interchangeable at the UI.
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Frequency int `json:"frequency"` // kHz
|
||||||
|
Band int `json:"band"` // 0 (SteppIR does not report a band index)
|
||||||
|
Direction int `json:"direction"` // 0 normal, 1 180°, 2 bidirectional
|
||||||
|
MotorsMoving int `json:"motors_moving"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
tr Transport
|
||||||
|
|
||||||
|
connMu sync.Mutex
|
||||||
|
conn io.ReadWriteCloser
|
||||||
|
|
||||||
|
// ioMu serialises EVERY exchange on the shared connection — a status query
|
||||||
|
// (write "?A" then read 11 bytes) and a command write must never interleave,
|
||||||
|
// or their bytes mix on the wire and both frames are corrupted. The status
|
||||||
|
// poll runs on one goroutine, tuning on another, so this is essential.
|
||||||
|
ioMu sync.Mutex
|
||||||
|
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
lastStatus *Status
|
||||||
|
lastSetKHz int
|
||||||
|
|
||||||
|
// A just-commanded direction is held until the controller's poll reports it —
|
||||||
|
// the motors take a second or two, and a stale poll would otherwise snap the
|
||||||
|
// UI back. Same trick as the Ultrabeam client.
|
||||||
|
pendingDir int
|
||||||
|
pendingDirAt time.Time
|
||||||
|
pendingDirSet bool
|
||||||
|
|
||||||
|
stopChan chan struct{}
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(tr Transport) *Client {
|
||||||
|
if tr.Baud <= 0 {
|
||||||
|
tr.Baud = 9600
|
||||||
|
}
|
||||||
|
return &Client{tr: tr, stopChan: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
c.running = true
|
||||||
|
go c.pollLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.running = false
|
||||||
|
close(c.stopChan)
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastSetKHz returns the frequency last commanded, or 0.
|
||||||
|
func (c *Client) LastSetKHz() int {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
return c.lastSetKHz
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() (*Status, error) {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
if c.lastStatus == nil {
|
||||||
|
return &Status{Connected: false}, nil
|
||||||
|
}
|
||||||
|
return c.lastStatus, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// open dials the transport. Callers hold connMu.
|
||||||
|
func (c *Client) open() (io.ReadWriteCloser, error) {
|
||||||
|
switch c.tr.Mode {
|
||||||
|
case "serial":
|
||||||
|
if c.tr.COM == "" {
|
||||||
|
return nil, fmt.Errorf("steppir: no serial port configured")
|
||||||
|
}
|
||||||
|
p, err := serial.Open(c.tr.COM, &serial.Mode{BaudRate: c.tr.Baud})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// A finite read timeout so a silent controller doesn't wedge the poll loop.
|
||||||
|
_ = p.SetReadTimeout(2 * time.Second)
|
||||||
|
return p, nil
|
||||||
|
default: // tcp
|
||||||
|
if c.tr.Host == "" {
|
||||||
|
return nil, fmt.Errorf("steppir: no host configured")
|
||||||
|
}
|
||||||
|
d := net.Dialer{Timeout: 5 * time.Second}
|
||||||
|
return d.Dial("tcp", net.JoinHostPort(c.tr.Host, fmt.Sprintf("%d", c.tr.Port)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) pollLoop() {
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn == nil {
|
||||||
|
conn, err := c.open()
|
||||||
|
if err != nil {
|
||||||
|
c.connMu.Unlock()
|
||||||
|
c.setDisconnected()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.conn = conn
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
st, err := c.queryStatus()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("steppir: status query failed, reconnecting: %v", err)
|
||||||
|
c.closeConn()
|
||||||
|
c.setDisconnected()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
st.Connected = true
|
||||||
|
c.statusMu.Lock()
|
||||||
|
if c.pendingDirSet {
|
||||||
|
if time.Since(c.pendingDirAt) > 4*time.Second || st.Direction == c.pendingDir {
|
||||||
|
c.pendingDirSet = false
|
||||||
|
} else {
|
||||||
|
st.Direction = c.pendingDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.lastStatus = st
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setDisconnected() {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = &Status{Connected: false}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) closeConn() {
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// setDeadline applies a read/write deadline on TCP; serial uses its own timeout.
|
||||||
|
func setDeadline(conn io.ReadWriteCloser, d time.Duration) {
|
||||||
|
if nc, ok := conn.(net.Conn); ok {
|
||||||
|
_ = nc.SetDeadline(time.Now().Add(d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) queryStatus() (*Status, error) {
|
||||||
|
c.connMu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.connMu.Unlock()
|
||||||
|
if conn == nil {
|
||||||
|
return nil, fmt.Errorf("steppir: not connected")
|
||||||
|
}
|
||||||
|
c.ioMu.Lock()
|
||||||
|
defer c.ioMu.Unlock()
|
||||||
|
setDeadline(conn, 3*time.Second)
|
||||||
|
if _, err := conn.Write([]byte("?A\r")); err != nil {
|
||||||
|
return nil, fmt.Errorf("write status cmd: %w", err)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 11)
|
||||||
|
if _, err := io.ReadFull(conn, buf); err != nil {
|
||||||
|
return nil, fmt.Errorf("read status: %w", err)
|
||||||
|
}
|
||||||
|
return parseStatus(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStatus decodes an 11-byte status frame.
|
||||||
|
func parseStatus(b []byte) (*Status, error) {
|
||||||
|
if len(b) < 11 {
|
||||||
|
return nil, fmt.Errorf("steppir: short status frame (%d bytes)", len(b))
|
||||||
|
}
|
||||||
|
freqHz := int(int32(binary.BigEndian.Uint32(b[2:6]))) * 10
|
||||||
|
active := b[6]
|
||||||
|
dir := decodeDir(b[7])
|
||||||
|
// active-motors byte: one bit per element that is currently moving.
|
||||||
|
// 0x04 driver · 0x08 DIR1 · 0x10 reflector · 0x20 DIR2 (mask 0x3C)
|
||||||
|
// Bit 0 (0x01) is documented as always set — not a motor. 0xFF means the
|
||||||
|
// controller just received a command, not motion. So "moving" is precisely
|
||||||
|
// "any real motor bit set", ignoring the always-on bit and the ack value.
|
||||||
|
const motorBits = 0x3C
|
||||||
|
moving := 0
|
||||||
|
if active != 0xFF && active&motorBits != 0 {
|
||||||
|
moving = 1
|
||||||
|
}
|
||||||
|
return &Status{Frequency: freqHz / 1000, Direction: dir, MotorsMoving: moving}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeDir(b byte) int {
|
||||||
|
switch b & 0xE0 {
|
||||||
|
case wireBi:
|
||||||
|
return DirBi
|
||||||
|
case wire180:
|
||||||
|
return Dir180
|
||||||
|
default:
|
||||||
|
return DirNormal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirWireByte(dir int) byte {
|
||||||
|
switch dir {
|
||||||
|
case Dir180:
|
||||||
|
return wire180
|
||||||
|
case DirBi:
|
||||||
|
return wireBi
|
||||||
|
default:
|
||||||
|
return wireNormal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSet frames a SET command: "@A" <freq be32 of Hz/10> 00 <dir> <cmd> 00 CR.
|
||||||
|
func buildSet(freqHz int, dir int, cmd byte) []byte {
|
||||||
|
var f [4]byte
|
||||||
|
binary.BigEndian.PutUint32(f[:], uint32(freqHz/10))
|
||||||
|
out := make([]byte, 0, 11)
|
||||||
|
out = append(out, '@', 'A')
|
||||||
|
out = append(out, f[:]...)
|
||||||
|
out = append(out, 0x00, dirWireByte(dir), cmd, 0x00, 0x0D)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) writeCmd(pkt []byte) error {
|
||||||
|
c.connMu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.connMu.Unlock()
|
||||||
|
if conn == nil {
|
||||||
|
return fmt.Errorf("steppir: not connected")
|
||||||
|
}
|
||||||
|
c.ioMu.Lock()
|
||||||
|
defer c.ioMu.Unlock()
|
||||||
|
log.Printf("steppir: → % X", pkt)
|
||||||
|
setDeadline(conn, 3*time.Second)
|
||||||
|
if _, err := conn.Write(pkt); err != nil {
|
||||||
|
c.closeConn()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The controller needs breathing room between commands.
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFrequency tunes the elements to freqKhz with the given direction.
|
||||||
|
//
|
||||||
|
// AUTOTRACK is (re-)enabled first, EVERY time: the controller ignores frequency
|
||||||
|
// sets unless it is in AUTOTRACK mode ("when not in AUTOTRACK only CALIBRATE and
|
||||||
|
// RETRACT work"), and it can be out of AUTOTRACK at power-on, after a Home, or if
|
||||||
|
// switched off on the front panel. Sending the 'R' command each tune is cheap and
|
||||||
|
// makes tuning work regardless of the controller's current mode — which is what
|
||||||
|
// was silently failing before.
|
||||||
|
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||||
|
if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil { // AUTOTRACK ON
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil { // set freq + dir
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastSetKHz = freqKhz
|
||||||
|
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDirection changes the pattern. SteppIR has no standalone direction command —
|
||||||
|
// it is a SET with the current frequency and the new direction byte.
|
||||||
|
func (c *Client) SetDirection(direction int) error {
|
||||||
|
khz := c.LastSetKHz()
|
||||||
|
if khz <= 0 {
|
||||||
|
if st, _ := c.GetStatus(); st != nil {
|
||||||
|
khz = st.Frequency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if khz <= 0 {
|
||||||
|
return fmt.Errorf("steppir: no frequency known yet — cannot set direction")
|
||||||
|
}
|
||||||
|
return c.SetFrequency(khz, direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retract homes the elements into the hubs (storage). This drops the controller
|
||||||
|
// out of AUTOTRACK, but that is handled transparently: the next SetFrequency
|
||||||
|
// re-issues AUTOTRACK ON before tuning.
|
||||||
|
func (c *Client) Retract() error {
|
||||||
|
// A valid frequency must accompany the command; reuse the last one.
|
||||||
|
khz := c.LastSetKHz()
|
||||||
|
if khz <= 0 {
|
||||||
|
if st, _ := c.GetStatus(); st != nil && st.Frequency > 0 {
|
||||||
|
khz = st.Frequency
|
||||||
|
} else {
|
||||||
|
khz = 14000 // any in-range value; the controller just homes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.writeCmd(buildSet(khz*1000, DirNormal, 'S'))
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package steppir
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The exact bytes are the correctness checksum. If buildSet ever drifts from the
|
||||||
|
// three-source-agreed layout, this fails — a wrong packet is a silently mistuned
|
||||||
|
// antenna, far worse than a compile error.
|
||||||
|
func TestBuildSetLayout(t *testing.T) {
|
||||||
|
// 14.074 MHz, normal, set-freq. freq/10 = 1_407_400 = 0x00 0x15 0x79 0xA8.
|
||||||
|
pkt := buildSet(14_074_000, DirNormal, '1')
|
||||||
|
want := []byte{'@', 'A', 0x00, 0x15, 0x79, 0xA8, 0x00, 0x00, '1', 0x00, 0x0D}
|
||||||
|
if len(pkt) != 11 {
|
||||||
|
t.Fatalf("packet is %d bytes, want 11", len(pkt))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if pkt[i] != want[i] {
|
||||||
|
t.Fatalf("byte %d = 0x%02X, want 0x%02X\n got %X\nwant %X", i, pkt[i], want[i], pkt, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Frequency must round-trip: bytes [2:6] × 10 = Hz.
|
||||||
|
if got := int(binary.BigEndian.Uint32(pkt[2:6])) * 10; got != 14_074_000 {
|
||||||
|
t.Fatalf("freq round-trip = %d, want 14074000", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSetDirectionAndCommand(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
dir int
|
||||||
|
cmd byte
|
||||||
|
wantDir byte
|
||||||
|
wantCmd byte
|
||||||
|
}{
|
||||||
|
{DirNormal, '1', 0x00, '1'},
|
||||||
|
{Dir180, '1', 0x40, '1'},
|
||||||
|
{DirBi, '1', 0x80, '1'},
|
||||||
|
{DirNormal, 'S', 0x00, 'S'}, // retract / home
|
||||||
|
{DirNormal, 'R', 0x00, 'R'}, // autotrack on
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
pkt := buildSet(21_000_000, c.dir, c.cmd)
|
||||||
|
if pkt[7] != c.wantDir {
|
||||||
|
t.Errorf("dir %d → byte 0x%02X, want 0x%02X", c.dir, pkt[7], c.wantDir)
|
||||||
|
}
|
||||||
|
if pkt[8] != c.wantCmd {
|
||||||
|
t.Errorf("cmd %q → byte 0x%02X, want 0x%02X", c.cmd, pkt[8], c.wantCmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A REAL status frame captured off an SDA controller (F4BPO's, 2026-07-15):
|
||||||
|
// 40 41 00 4C C5 84 00 87 30 38 0D — 50.313 MHz, idle, bidirectional, interface
|
||||||
|
// version "08". Using the actual device output (rather than hand-built bytes)
|
||||||
|
// pins parseStatus to real hardware, and independently confirms the ÷10 wire
|
||||||
|
// scale: 0x4CC584 × 10 = 50 313 000 Hz, a genuine 6 m frequency.
|
||||||
|
func TestParseStatusRealFrame(t *testing.T) {
|
||||||
|
frame := []byte{0x40, 0x41, 0x00, 0x4C, 0xC5, 0x84, 0x00, 0x87, 0x30, 0x38, 0x0D}
|
||||||
|
st, err := parseStatus(frame)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Frequency != 50313 {
|
||||||
|
t.Errorf("freq = %d kHz, want 50313 (50.313 MHz)", st.Frequency)
|
||||||
|
}
|
||||||
|
if st.Direction != DirBi { // 0x87 & 0xE0 = 0x80 = bidirectional
|
||||||
|
t.Errorf("direction = %d, want %d (bidirectional)", st.Direction, DirBi)
|
||||||
|
}
|
||||||
|
if st.MotorsMoving != 0 {
|
||||||
|
t.Errorf("moving = %d, want 0 (motors byte 0x00)", st.MotorsMoving)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStatus decodes what the controller sends back — the inverse of buildSet's
|
||||||
|
// frequency field, plus the direction nibble and the motors-busy byte.
|
||||||
|
func TestParseStatus(t *testing.T) {
|
||||||
|
frame := []byte{0x40, 0x41, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '0', '8', 0x0D}
|
||||||
|
st, err := parseStatus(frame)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Frequency != 14074 {
|
||||||
|
t.Errorf("freq = %d kHz, want 14074", st.Frequency)
|
||||||
|
}
|
||||||
|
if st.Direction != Dir180 {
|
||||||
|
t.Errorf("direction = %d, want %d (180°)", st.Direction, Dir180)
|
||||||
|
}
|
||||||
|
if st.MotorsMoving != 0 { // 0x01 is the always-set bit, not motion
|
||||||
|
t.Errorf("moving = %d, want 0 (only the always-on bit set)", st.MotorsMoving)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Motors busy: a bit beyond 0x01 is set.
|
||||||
|
frame[6] = 0x07
|
||||||
|
st, _ = parseStatus(frame)
|
||||||
|
if st.MotorsMoving == 0 {
|
||||||
|
t.Error("active-motors 0x07 should read as moving")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0xFF is "command received", not motion.
|
||||||
|
frame[6] = 0xFF
|
||||||
|
st, _ = parseStatus(frame)
|
||||||
|
if st.MotorsMoving != 0 {
|
||||||
|
t.Error("active-motors 0xFF (command received) must not read as moving")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
// Package uls resolves a US amateur callsign to its county and grid, offline,
|
||||||
|
// from the FCC ULS licence database cross-referenced with a ZIP→county/lat-lon
|
||||||
|
// table. It backs the US Counties (USA-CA) award and county hunting: an FCC
|
||||||
|
// spot or a bare CW/SSB spot carries only a callsign, and this turns that into
|
||||||
|
// a county with no per-lookup API call.
|
||||||
|
//
|
||||||
|
// Data lives in its OWN local SQLite file (data/uls.db), never in the logbook:
|
||||||
|
// it is ~800k rows of static reference data that would only bloat the log (and
|
||||||
|
// crawl over a remote MySQL link). It is downloaded on demand, not shipped.
|
||||||
|
//
|
||||||
|
// Sources, both public and free:
|
||||||
|
// - FCC ULS Amateur, full database: l_amat.zip → EN.dat (pipe-delimited).
|
||||||
|
// Fields used: [4] call_sign, [17] state, [18] zip_code.
|
||||||
|
// - GeoNames US postal codes: US.zip → US.txt (tab-delimited).
|
||||||
|
// Fields used: [1] zip, [4] state, [5] county, [9] lat, [10] lon.
|
||||||
|
//
|
||||||
|
// The county from a ZIP is the ZIP's primary county — a ZIP can straddle a line,
|
||||||
|
// so this is ~98% right for fixed stations (rovers/portables need the from-air
|
||||||
|
// grid, which arrives separately via heard_geo). Good enough to hunt with.
|
||||||
|
package uls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errFCCMaintenance is raised when the FCC ULS download host bounces us to its
|
||||||
|
// maintenance page instead of serving the file (a frequent, FCC-side event).
|
||||||
|
var errFCCMaintenance = errors.New("fcc uls under maintenance")
|
||||||
|
|
||||||
|
// Default download URLs (overridable in Import for tests).
|
||||||
|
const (
|
||||||
|
fccAmateurURL = "https://data.fcc.gov/download/pub/uls/complete/l_amat.zip"
|
||||||
|
geoNamesURL = "https://download.geonames.org/export/zip/US.zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Location is a resolved callsign's home county + grid.
|
||||||
|
type Location struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
County string `json:"county"` // GeoNames county name (e.g. "Middlesex")
|
||||||
|
Grid string `json:"grid"` // 6-char Maidenhead from the ZIP centroid
|
||||||
|
}
|
||||||
|
|
||||||
|
// CNTY renders the ADIF "STATE,County" form for stamping a QSO's cnty field.
|
||||||
|
func (l Location) CNTY() string {
|
||||||
|
if l.State == "" || l.County == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return l.State + "," + l.County
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store owns the local uls.db connection.
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
mu sync.RWMutex // guards the whole DB during a re-import (DELETE+bulk INSERT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens (creating if needed) the ULS SQLite store at path.
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
|
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS uls_callsign (
|
||||||
|
callsign TEXT PRIMARY KEY,
|
||||||
|
state TEXT NOT NULL DEFAULT '',
|
||||||
|
county TEXT NOT NULL DEFAULT '',
|
||||||
|
grid TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS uls_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL DEFAULT ''
|
||||||
|
);`); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Store{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Close() error { return s.db.Close() }
|
||||||
|
|
||||||
|
// Count returns how many callsigns are loaded.
|
||||||
|
func (s *Store) Count() int {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
var n int
|
||||||
|
s.db.QueryRow(`SELECT COUNT(*) FROM uls_callsign`).Scan(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatedAt returns when the store was last imported (zero if never).
|
||||||
|
func (s *Store) UpdatedAt() time.Time {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
var v string
|
||||||
|
if err := s.db.QueryRow(`SELECT value FROM uls_meta WHERE key='updated_at'`).Scan(&v); err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
t, _ := time.Parse(time.RFC3339, v)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve looks up a callsign's home county + grid. ok=false if unknown or the
|
||||||
|
// store is empty.
|
||||||
|
func (s *Store) Resolve(callsign string) (Location, bool) {
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(callsign))
|
||||||
|
if call == "" {
|
||||||
|
return Location{}, false
|
||||||
|
}
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
var l Location
|
||||||
|
err := s.db.QueryRow(`SELECT state, county, grid FROM uls_callsign WHERE callsign=?`, call).
|
||||||
|
Scan(&l.State, &l.County, &l.Grid)
|
||||||
|
if err != nil || l.County == "" {
|
||||||
|
return Location{}, false
|
||||||
|
}
|
||||||
|
return l, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress reports import stages to the caller (0..100 within a stage).
|
||||||
|
type Progress func(stage string, pct int)
|
||||||
|
|
||||||
|
// zipRow is one ZIP's primary county + centroid.
|
||||||
|
type zipRow struct {
|
||||||
|
state, county string
|
||||||
|
lat, lon float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import downloads the FCC ULS + GeoNames data, rebuilds the callsign→county
|
||||||
|
// table, and records the timestamp. It replaces the table atomically: on any
|
||||||
|
// error the previous contents are left intact. tmpDir is where the (large) zips
|
||||||
|
// are streamed; "" uses the OS temp dir.
|
||||||
|
func (s *Store) Import(ctx context.Context, tmpDir string, prog Progress) error {
|
||||||
|
if prog == nil {
|
||||||
|
prog = func(string, int) {}
|
||||||
|
}
|
||||||
|
if tmpDir == "" {
|
||||||
|
tmpDir = os.TempDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) ZIP→county/lat-lon crosswalk (small).
|
||||||
|
prog("Downloading ZIP crosswalk", 0)
|
||||||
|
geoPath := filepath.Join(tmpDir, "opslog_geonames_us.zip")
|
||||||
|
if err := download(ctx, geoNamesURL, geoPath, nil); err != nil {
|
||||||
|
return fmt.Errorf("download GeoNames: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(geoPath)
|
||||||
|
prog("Parsing ZIP crosswalk", 50)
|
||||||
|
zipmap, err := parseGeoNames(geoPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse GeoNames: %w", err)
|
||||||
|
}
|
||||||
|
if len(zipmap) == 0 {
|
||||||
|
return fmt.Errorf("GeoNames crosswalk is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) FCC ULS full amateur database (large).
|
||||||
|
prog("Downloading FCC ULS database", 0)
|
||||||
|
amatPath := filepath.Join(tmpDir, "opslog_l_amat.zip")
|
||||||
|
if err := download(ctx, fccAmateurURL, amatPath, func(pct int) { prog("Downloading FCC ULS database", pct) }); err != nil {
|
||||||
|
return fmt.Errorf("download FCC ULS: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(amatPath)
|
||||||
|
|
||||||
|
// 3) Parse EN.dat, join the crosswalk, rebuild the table.
|
||||||
|
prog("Building county database", 0)
|
||||||
|
return s.rebuild(ctx, amatPath, zipmap, prog)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebuild streams EN.dat out of the FCC zip and replaces uls_callsign in one
|
||||||
|
// transaction (old data survives a failure).
|
||||||
|
func (s *Store) rebuild(ctx context.Context, amatZip string, zipmap map[string]zipRow, prog Progress) error {
|
||||||
|
zr, err := zip.OpenReader(amatZip)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open FCC zip: %w", err)
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
|
||||||
|
var en *zip.File
|
||||||
|
for _, f := range zr.File {
|
||||||
|
if strings.EqualFold(filepath.Base(f.Name), "EN.dat") {
|
||||||
|
en = f
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if en == nil {
|
||||||
|
return fmt.Errorf("EN.dat not found in FCC zip")
|
||||||
|
}
|
||||||
|
rc, err := en.Open()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open EN.dat: %w", err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM uls_callsign`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stmt, err := tx.PrepareContext(ctx, `INSERT OR REPLACE INTO uls_callsign(callsign,state,county,grid) VALUES(?,?,?,?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
sc := bufio.NewScanner(rc)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
var n, kept int
|
||||||
|
for sc.Scan() {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
f := strings.Split(sc.Text(), "|")
|
||||||
|
if len(f) < 19 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(f[4]))
|
||||||
|
if call == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
zip5 := zip5Of(f[18])
|
||||||
|
zr, ok := zipmap[zip5]
|
||||||
|
if !ok {
|
||||||
|
continue // no crosswalk entry → can't place it
|
||||||
|
}
|
||||||
|
state := strings.ToUpper(strings.TrimSpace(f[17]))
|
||||||
|
if state == "" {
|
||||||
|
state = zr.state
|
||||||
|
}
|
||||||
|
if _, err := stmt.ExecContext(ctx, call, state, zr.county, grid6(zr.lat, zr.lon)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kept++
|
||||||
|
if kept%50000 == 0 {
|
||||||
|
prog("Building county database", int(math.Min(99, float64(kept)/8000)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
return fmt.Errorf("read EN.dat: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO uls_meta(key,value) VALUES('updated_at',?)`,
|
||||||
|
time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
prog("Done", 100)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGeoNames reads US.txt out of the GeoNames zip into a zip→row map,
|
||||||
|
// keeping the first (primary) county seen for each ZIP.
|
||||||
|
func parseGeoNames(zipPath string) (map[string]zipRow, error) {
|
||||||
|
zr, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
var txt *zip.File
|
||||||
|
for _, f := range zr.File {
|
||||||
|
if strings.EqualFold(filepath.Base(f.Name), "US.txt") {
|
||||||
|
txt = f
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if txt == nil {
|
||||||
|
return nil, fmt.Errorf("US.txt not found")
|
||||||
|
}
|
||||||
|
rc, err := txt.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
out := make(map[string]zipRow, 45000)
|
||||||
|
sc := bufio.NewScanner(rc)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 256*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
f := strings.Split(sc.Text(), "\t")
|
||||||
|
if len(f) < 11 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
zip5 := strings.TrimSpace(f[1])
|
||||||
|
if zip5 == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, dup := out[zip5]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lat := parseFloat(f[9])
|
||||||
|
lon := parseFloat(f[10])
|
||||||
|
out[zip5] = zipRow{
|
||||||
|
state: strings.ToUpper(strings.TrimSpace(f[4])),
|
||||||
|
county: strings.TrimSpace(f[5]),
|
||||||
|
lat: lat,
|
||||||
|
lon: lon,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, sc.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// download streams url to dest, reporting percent when the content length is
|
||||||
|
// known and prog is non-nil.
|
||||||
|
func download(ctx context.Context, url, dest string, prog func(pct int)) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Catch the FCC maintenance bounce BEFORE following it: data.fcc.gov redirects
|
||||||
|
// to www.fcc.gov/system-maintenance during maintenance windows, and that page
|
||||||
|
// then HTTP/2-stream-errors — which surfaced as a cryptic "INTERNAL_ERROR"
|
||||||
|
// instead of a plain "try again later".
|
||||||
|
client := &http.Client{
|
||||||
|
CheckRedirect: func(r *http.Request, via []*http.Request) error {
|
||||||
|
if strings.Contains(r.URL.String(), "system-maintenance") {
|
||||||
|
return errFCCMaintenance
|
||||||
|
}
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return errors.New("stopped after 10 redirects")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errFCCMaintenance) || strings.Contains(err.Error(), "system-maintenance") {
|
||||||
|
return fmt.Errorf("the FCC ULS download service is under maintenance (fcc.gov redirected to its maintenance page) — please try again later")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
f, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var body io.Reader = resp.Body
|
||||||
|
if prog != nil && resp.ContentLength > 0 {
|
||||||
|
body = &progReader{r: resp.Body, total: resp.ContentLength, prog: prog}
|
||||||
|
}
|
||||||
|
_, err = io.Copy(f, body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type progReader struct {
|
||||||
|
r io.Reader
|
||||||
|
total int64
|
||||||
|
read int64
|
||||||
|
last int
|
||||||
|
prog func(pct int)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *progReader) Read(b []byte) (int, error) {
|
||||||
|
n, err := p.r.Read(b)
|
||||||
|
p.read += int64(n)
|
||||||
|
if pct := int(p.read * 100 / p.total); pct != p.last {
|
||||||
|
p.last = pct
|
||||||
|
p.prog(pct)
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func zip5Of(z string) string {
|
||||||
|
z = strings.TrimSpace(z)
|
||||||
|
if len(z) > 5 {
|
||||||
|
z = z[:5]
|
||||||
|
}
|
||||||
|
return z
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFloat(s string) float64 {
|
||||||
|
var v float64
|
||||||
|
fmt.Sscanf(strings.TrimSpace(s), "%g", &v)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// grid6 converts latitude/longitude to a 6-character Maidenhead locator.
|
||||||
|
func grid6(lat, lon float64) string {
|
||||||
|
if lat == 0 && lon == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lon += 180
|
||||||
|
lat += 90
|
||||||
|
if lon < 0 || lon >= 360 || lat < 0 || lat >= 180 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
f0 := int(lon / 20)
|
||||||
|
f1 := int(lat / 10)
|
||||||
|
sq0 := int(math.Mod(lon, 20) / 2)
|
||||||
|
sq1 := int(math.Mod(lat, 10) / 1)
|
||||||
|
su0 := int(math.Mod(lon, 2) / (2.0 / 24))
|
||||||
|
su1 := int(math.Mod(lat, 1) / (1.0 / 24))
|
||||||
|
return string([]byte{
|
||||||
|
byte('A' + f0), byte('A' + f1),
|
||||||
|
byte('0' + sq0), byte('0' + sq1),
|
||||||
|
byte('a' + su0), byte('a' + su1),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package uls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGrid6(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
lat, lon float64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{38.90, -77.03, "FM18lw"}, // Washington DC
|
||||||
|
{40.71, -74.00, "FN30xr"}, // New York
|
||||||
|
{34.05, -118.24, "DM04vd"},// Los Angeles
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := grid6(c.lat, c.lon); got[:4] != c.want[:4] {
|
||||||
|
t.Errorf("grid6(%v,%v)=%q want field/square %q", c.lat, c.lon, got, c.want[:4])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseGeoNames runs against the real GeoNames US.zip if present in the
|
||||||
|
// scratchpad (downloaded during development); skipped otherwise.
|
||||||
|
func TestParseGeoNames(t *testing.T) {
|
||||||
|
path := os.Getenv("GEONAMES_ZIP")
|
||||||
|
if path == "" {
|
||||||
|
t.Skip("set GEONAMES_ZIP to the US.zip path to run")
|
||||||
|
}
|
||||||
|
m, err := parseGeoNames(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(m) < 30000 {
|
||||||
|
t.Fatalf("expected >30k ZIPs, got %d", len(m))
|
||||||
|
}
|
||||||
|
// A known ZIP: 20500 = The White House, DC.
|
||||||
|
if r, ok := m["20500"]; !ok || r.state != "DC" {
|
||||||
|
t.Errorf("ZIP 20500 = %+v (ok=%v)", r, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -470,6 +470,27 @@ func (c *Client) queryProgress() ([]int, error) {
|
|||||||
return []int{total, current}, nil
|
return []int{total, current}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadElements reads the current per-element lengths for the active band
|
||||||
|
// (CMD_READ_BANDS). The controller is write-only for ModifyElement, so this is
|
||||||
|
// the only way to see the current lengths — needed so the operator isn't
|
||||||
|
// adjusting blind. The reply payload layout is not documented in the code, so we
|
||||||
|
// LOG it verbatim (once) and parse a best guess: element lengths as 16-bit
|
||||||
|
// little-endian values, matching how ModifyElement WRITES a length. Confirm the
|
||||||
|
// format from the logged bytes on real hardware, then tighten the parse.
|
||||||
|
func (c *Client) ReadElements() ([]int, error) {
|
||||||
|
payload, err := c.sendCommand(CMD_READ_BANDS, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Printf("Ultrabeam: READ_BANDS payload (% X) — %d bytes", payload, len(payload))
|
||||||
|
// Best-guess parse: consecutive 16-bit LE values = element lengths in mm.
|
||||||
|
out := make([]int, 0, len(payload)/2)
|
||||||
|
for i := 0; i+1 < len(payload); i += 2 {
|
||||||
|
out = append(out, int(payload[i])|int(payload[i+1])<<8)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetFrequency changes frequency and optional direction (command 3)
|
// SetFrequency changes frequency and optional direction (command 3)
|
||||||
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||||
// Trace WHO asked for the change — the caller's function + line — so an
|
// Trace WHO asked for the change — the caller's function + line — so an
|
||||||
|
|||||||
+193
-10
@@ -1,13 +1,26 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// emitLiveStatusChanged nudges the frontend to re-read GetLiveStations right when
|
||||||
|
// the shared table actually changes (a row appeared or was removed) — so the
|
||||||
|
// "stations on air" widget updates the instant a QSO is published, matching the
|
||||||
|
// bottom ON-AIR badge instead of waiting for its poll.
|
||||||
|
func (a *App) emitLiveStatusChanged() {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "livestatus:updated", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Live operator status — for multi-operator events on a SHARED MySQL logbook
|
// Live operator status — for multi-operator events on a SHARED MySQL logbook
|
||||||
// (e.g. a special-event call like TM74FR with several ops on different bands).
|
// (e.g. a special-event call like TM74FR with several ops on different bands).
|
||||||
// Each OpsLog instance heartbeats its current activity (operator call + station
|
// Each OpsLog instance heartbeats its current activity (operator call + station
|
||||||
@@ -19,6 +32,23 @@ import (
|
|||||||
|
|
||||||
const keyLiveStatusEnabled = "livestatus.enabled"
|
const keyLiveStatusEnabled = "livestatus.enabled"
|
||||||
|
|
||||||
|
// liveOnlineWindow is how long after the last logged contact an operator still
|
||||||
|
// counts as "on air". Leaving the log open without working anyone flips them
|
||||||
|
// offline once this elapses; logging a new QSO flips them back online.
|
||||||
|
const liveOnlineWindow = 5 * time.Minute
|
||||||
|
|
||||||
|
// noteLiveQSO records that this operator just logged a new contact and pushes the
|
||||||
|
// live status right away, so they flip back to online the instant they work
|
||||||
|
// someone. Called from the logging paths (manual entry, UDP auto-log).
|
||||||
|
func (a *App) noteLiveQSO() {
|
||||||
|
a.liveActMu.Lock()
|
||||||
|
a.liveLastQSOAt = time.Now()
|
||||||
|
a.liveActMu.Unlock()
|
||||||
|
if a.liveStatusActive() {
|
||||||
|
go a.publishLiveStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetLiveStatusEnabled reports whether this operator publishes live status.
|
// GetLiveStatusEnabled reports whether this operator publishes live status.
|
||||||
func (a *App) GetLiveStatusEnabled() bool {
|
func (a *App) GetLiveStatusEnabled() bool {
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
@@ -50,11 +80,61 @@ func (a *App) SetLiveStatusEnabled(on bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedLiveLastQSO primes liveLastQSOAt from the DB at launch, so an operator who
|
||||||
|
// worked someone shortly before (re)starting OpsLog is shown "on air" right away
|
||||||
|
// instead of offline until their next QSO.
|
||||||
|
func (a *App) seedLiveLastQSO() {
|
||||||
|
if a.qso == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
op, _ := a.liveStatusOperator()
|
||||||
|
if op == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t, ok := a.qso.LastQSOTime(a.ctx, op); ok {
|
||||||
|
a.liveActMu.Lock()
|
||||||
|
if a.liveLastQSOAt.IsZero() {
|
||||||
|
a.liveLastQSOAt = t
|
||||||
|
}
|
||||||
|
a.liveActMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveLastQSOTime is the authoritative "last contact" instant for this operator:
|
||||||
|
// the most recent of the in-memory stamp (this session's local logs, updated
|
||||||
|
// instantly) AND the DB (a contact that arrived via the SHARED logbook from another
|
||||||
|
// station, or one logged before launch). Used by both the published status and the
|
||||||
|
// UI badge so on-air/offline is right in every multi-op case.
|
||||||
|
func (a *App) liveLastQSOTime() time.Time {
|
||||||
|
a.liveActMu.Lock()
|
||||||
|
last := a.liveLastQSOAt
|
||||||
|
a.liveActMu.Unlock()
|
||||||
|
if a.qso != nil {
|
||||||
|
if op, _ := a.liveStatusOperator(); op != "" {
|
||||||
|
if t, ok := a.qso.LastQSOTime(a.ctx, op); ok && t.After(last) {
|
||||||
|
last = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveLastQSOAgeSec returns seconds since this operator's last logged QSO, or -1 if
|
||||||
|
// none is known — the UI polls it for the "on air" badge.
|
||||||
|
func (a *App) LiveLastQSOAgeSec() int {
|
||||||
|
last := a.liveLastQSOTime()
|
||||||
|
if last.IsZero() {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return int(time.Since(last).Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
// liveStatusLoop heartbeats the current activity while enabled. Started once at
|
// liveStatusLoop heartbeats the current activity while enabled. Started once at
|
||||||
// startup; cheap no-op when disabled or not on MySQL.
|
// startup; cheap no-op when disabled or not on MySQL.
|
||||||
func (a *App) liveStatusLoop() {
|
func (a *App) liveStatusLoop() {
|
||||||
defer func() { _ = recover() }() // never crash the app from here
|
defer func() { _ = recover() }() // never crash the app from here
|
||||||
applog.Printf("livestatus: loop started")
|
applog.Printf("livestatus: loop started")
|
||||||
|
a.seedLiveLastQSO() // so online/offline is right at launch, not only after the next QSO
|
||||||
a.publishLiveStatus() // attempt immediately, don't wait the first tick
|
a.publishLiveStatus() // attempt immediately, don't wait the first tick
|
||||||
t := time.NewTicker(15 * time.Second)
|
t := time.NewTicker(15 * time.Second)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
@@ -91,10 +171,23 @@ func (a *App) liveStatusOperator() (op, station string) {
|
|||||||
// ReportLiveActivity is called by the UI with the current entry-strip freq/band/
|
// ReportLiveActivity is called by the UI with the current entry-strip freq/band/
|
||||||
// mode, used as a fallback for live status when the CAT isn't connected.
|
// mode, used as a fallback for live status when the CAT isn't connected.
|
||||||
func (a *App) ReportLiveActivity(freqHz int64, band, mode string) {
|
func (a *App) ReportLiveActivity(freqHz int64, band, mode string) {
|
||||||
|
nb := strings.ToUpper(strings.TrimSpace(band))
|
||||||
|
nm := strings.ToUpper(strings.TrimSpace(mode))
|
||||||
a.liveActMu.Lock()
|
a.liveActMu.Lock()
|
||||||
|
changed := a.liveFreqHz != freqHz || a.liveBand != nb || a.liveMode != nm
|
||||||
a.liveFreqHz = freqHz
|
a.liveFreqHz = freqHz
|
||||||
a.liveBand = strings.ToUpper(strings.TrimSpace(band))
|
a.liveBand = nb
|
||||||
a.liveMode = strings.ToUpper(strings.TrimSpace(mode))
|
a.liveMode = nm
|
||||||
|
// Push a fresh row PROMPTLY when the operator's freq/band/mode changes, instead
|
||||||
|
// of waiting for the next 15 s heartbeat — otherwise "who's on air" showed a
|
||||||
|
// stale band and took up to a minute to catch up. Debounced so spinning the VFO
|
||||||
|
// doesn't hammer the shared MySQL.
|
||||||
|
if changed {
|
||||||
|
if a.livePublishTimer != nil {
|
||||||
|
a.livePublishTimer.Stop()
|
||||||
|
}
|
||||||
|
a.livePublishTimer = time.AfterFunc(1500*time.Millisecond, func() { a.publishLiveStatus() })
|
||||||
|
}
|
||||||
a.liveActMu.Unlock()
|
a.liveActMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,33 +226,123 @@ func (a *App) publishLiveStatus() {
|
|||||||
mode = a.liveMode
|
mode = a.liveMode
|
||||||
}
|
}
|
||||||
a.liveActMu.Unlock()
|
a.liveActMu.Unlock()
|
||||||
|
lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB)
|
||||||
|
// On air = a new contact was logged within the window. An operator who leaves
|
||||||
|
// the log open but stops working goes offline after `liveOnlineWindow`; the next
|
||||||
|
// QSO puts them back on. never-logged (zero time) → offline.
|
||||||
|
online := !lastQSO.IsZero() && time.Since(lastQSO) < liveOnlineWindow
|
||||||
if err := a.ensureLiveStatusTable(); err != nil {
|
if err := a.ensureLiveStatusTable(); err != nil {
|
||||||
applog.Printf("livestatus: CREATE TABLE failed: %v", err)
|
applog.Printf("livestatus: CREATE TABLE failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Offline → REMOVE the row entirely, not just flip a flag: a status page that
|
||||||
|
// lists the present rows (the common case, keyed on updated_at) then shows the
|
||||||
|
// operator as gone without having to read the online column. The row reappears
|
||||||
|
// on the next QSO. This is the whole point — no one shows on air when they're not.
|
||||||
|
if !online {
|
||||||
|
if _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op); err != nil {
|
||||||
|
applog.Printf("livestatus: offline DELETE failed: %v", err)
|
||||||
|
}
|
||||||
|
a.emitLiveStatusChanged()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastQSOArg := lastQSO.UTC()
|
||||||
_, err := a.logDb.ExecContext(a.ctx,
|
_, err := a.logDb.ExecContext(a.ctx,
|
||||||
"INSERT INTO live_status (operator, station, freq_hz, band, mode, updated_at) "+
|
"INSERT INTO live_status (operator, station, freq_hz, band, mode, online, version, last_qso_at, updated_at) "+
|
||||||
"VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
||||||
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
|
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
|
||||||
"band=VALUES(band), mode=VALUES(mode), updated_at=UTC_TIMESTAMP()",
|
"band=VALUES(band), mode=VALUES(mode), online=VALUES(online), version=VALUES(version), "+
|
||||||
op, station, freqHz, band, mode)
|
"last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()",
|
||||||
|
op, station, freqHz, band, mode, 1, appVersion, lastQSOArg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
applog.Printf("livestatus: INSERT failed: %v", err)
|
applog.Printf("livestatus: INSERT failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s", op, station, freqHz, band, mode)
|
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s ON AIR", op, station, freqHz, band, mode)
|
||||||
|
a.emitLiveStatusChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveStation is one operator's live status for the multi-op "who's on air" widget.
|
||||||
|
type LiveStation struct {
|
||||||
|
Operator string `json:"operator"`
|
||||||
|
Station string `json:"station"`
|
||||||
|
FreqHz int64 `json:"freq_hz"`
|
||||||
|
Band string `json:"band"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Online bool `json:"online"` // logged a QSO in the last 5 min
|
||||||
|
Version string `json:"version"` // that operator's OpsLog version
|
||||||
|
AgeSec int `json:"age_sec"` // seconds since their last heartbeat (stale = OpsLog closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLiveStations returns every operator's live status from the shared MySQL
|
||||||
|
// logbook (empty on a local SQLite logbook). Rows whose heartbeat is very stale
|
||||||
|
// (OpsLog closed without clearing its row) are dropped. Online stations first.
|
||||||
|
func (a *App) GetLiveStations() []LiveStation {
|
||||||
|
if a.logDb == nil || a.dbBackend != "mysql" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := a.ensureLiveStatusTable(); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rows, err := a.logDb.QueryContext(a.ctx,
|
||||||
|
"SELECT operator, COALESCE(station,''), COALESCE(freq_hz,0), COALESCE(band,''), "+
|
||||||
|
"COALESCE(mode,''), COALESCE(online,0), COALESCE(version,''), "+
|
||||||
|
"TIMESTAMPDIFF(SECOND, updated_at, UTC_TIMESTAMP()) "+
|
||||||
|
"FROM live_status ORDER BY online DESC, operator")
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("livestatus: list failed: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []LiveStation{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s LiveStation
|
||||||
|
var online int
|
||||||
|
var age sql.NullInt64
|
||||||
|
if err := rows.Scan(&s.Operator, &s.Station, &s.FreqHz, &s.Band, &s.Mode, &online, &s.Version, &age); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Drop rows from an OpsLog that hasn't heartbeated in a while (closed): the
|
||||||
|
// heartbeat is every 15 s, so > 3 min means it's gone.
|
||||||
|
if age.Valid && age.Int64 > 180 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.Online = online == 1
|
||||||
|
if age.Valid {
|
||||||
|
s.AgeSec = int(age.Int64)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ensureLiveStatusTable() error {
|
func (a *App) ensureLiveStatusTable() error {
|
||||||
_, err := a.logDb.ExecContext(a.ctx,
|
if _, err := a.logDb.ExecContext(a.ctx,
|
||||||
"CREATE TABLE IF NOT EXISTS live_status ("+
|
"CREATE TABLE IF NOT EXISTS live_status ("+
|
||||||
"operator VARCHAR(32) PRIMARY KEY, "+
|
"operator VARCHAR(32) PRIMARY KEY, "+
|
||||||
"station VARCHAR(32), "+
|
"station VARCHAR(32), "+
|
||||||
"freq_hz BIGINT, "+
|
"freq_hz BIGINT, "+
|
||||||
"band VARCHAR(16), "+
|
"band VARCHAR(16), "+
|
||||||
"mode VARCHAR(16), "+
|
"mode VARCHAR(16), "+
|
||||||
"updated_at DATETIME)")
|
"online TINYINT DEFAULT 0, "+
|
||||||
return err
|
"version VARCHAR(32), "+
|
||||||
|
"last_qso_at DATETIME NULL, "+
|
||||||
|
"updated_at DATETIME)"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Add newer columns to a table created by an older build. MySQL has no portable
|
||||||
|
// "ADD COLUMN IF NOT EXISTS", so just run the ALTERs and ignore the duplicate-
|
||||||
|
// column error when they already exist.
|
||||||
|
for _, ddl := range []string{
|
||||||
|
"ALTER TABLE live_status ADD COLUMN online TINYINT DEFAULT 0",
|
||||||
|
"ALTER TABLE live_status ADD COLUMN version VARCHAR(32)",
|
||||||
|
"ALTER TABLE live_status ADD COLUMN last_qso_at DATETIME NULL",
|
||||||
|
} {
|
||||||
|
if _, err := a.logDb.ExecContext(a.ctx, ddl); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
|
||||||
|
applog.Printf("livestatus: %q: %v", ddl, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// clearLiveStatus removes this operator's row (on disable / shutdown).
|
// clearLiveStatus removes this operator's row (on disable / shutdown).
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
@@ -35,33 +36,92 @@ func profileArg(args []string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasFlag reports whether flag is present in args.
|
||||||
|
func hasFlag(args []string, flag string) bool {
|
||||||
|
for _, a := range args {
|
||||||
|
if a == flag {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// acquireInstance grabs the single-instance mutex. On a normal launch it's a plain
|
||||||
|
// try (fail → another OpsLog is running, so exit). On a --post-update relaunch the
|
||||||
|
// previous instance may still be shutting down and holding the mutex, so retry for
|
||||||
|
// a few seconds until it frees.
|
||||||
|
func acquireInstance(postUpdate bool) bool {
|
||||||
|
if acquireSingleInstance() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !postUpdate {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(20 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
if acquireSingleInstance() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Single-instance guard: if OpsLog is already running, focus that window and
|
// Single-instance guard: if OpsLog is already running, focus that window and
|
||||||
// exit instead of spawning a duplicate. A second process would open its own
|
// exit instead of spawning a duplicate. A second process would open its own
|
||||||
// CAT (FlexRadio) connection and Ultrabeam follow loop, and the two would
|
// CAT (FlexRadio) connection and Ultrabeam follow loop, and the two would
|
||||||
// fight over the rig/antenna frequency — the cause of "the antenna re-tunes on
|
// fight over the rig/antenna frequency — the cause of "the antenna re-tunes on
|
||||||
// its own" when a windowless zombie instance was left running.
|
// its own" when a windowless zombie instance was left running.
|
||||||
if !acquireSingleInstance() {
|
// A --post-update relaunch (from the auto-updater) may start while the previous
|
||||||
|
// instance is still exiting and holding the single-instance mutex — wait for it
|
||||||
|
// to free instead of bailing out. Then clear the old exe it left behind.
|
||||||
|
postUpdate := hasFlag(os.Args[1:], "--post-update")
|
||||||
|
if !acquireInstance(postUpdate) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if postUpdate {
|
||||||
|
cleanupOldUpdateBinary()
|
||||||
|
}
|
||||||
|
|
||||||
// Create an instance of the app structure
|
// Create an instance of the app structure
|
||||||
app := NewApp()
|
app := NewApp()
|
||||||
app.startupProfile = profileArg(os.Args[1:])
|
app.startupProfile = profileArg(os.Args[1:])
|
||||||
|
|
||||||
|
// Restore the window's SIZE and maximised state at CREATION, from the geometry
|
||||||
|
// saved on last close. Doing it here (not after startup) is what makes the
|
||||||
|
// window open already at the right size instead of maximising then snapping
|
||||||
|
// smaller. Position can't be set through options, so it is applied while the
|
||||||
|
// window is still hidden (domReady) — invisible, so no jump. First run, or a
|
||||||
|
// window closed maximised, keeps the historical maximised default.
|
||||||
|
width, height := 1400, 900
|
||||||
|
startState := options.Maximised
|
||||||
|
if dataDir, err := userDataDir(); err == nil {
|
||||||
|
if ws, ok := readWindowState(dataDir); ok && !ws.Maximised &&
|
||||||
|
ws.Width >= 1100 && ws.Height >= 700 && ws.Width <= 8000 && ws.Height <= 6000 {
|
||||||
|
width, height = ws.Width, ws.Height
|
||||||
|
startState = options.Normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create application with options
|
// Create application with options
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
Title: "OpsLog",
|
Title: "OpsLog",
|
||||||
Width: 1400,
|
Width: width,
|
||||||
Height: 900,
|
Height: height,
|
||||||
MinWidth: 1100,
|
MinWidth: 1100,
|
||||||
MinHeight: 700,
|
MinHeight: 700,
|
||||||
WindowStartState: options.Maximised,
|
WindowStartState: startState,
|
||||||
|
// Start hidden and reveal only once the saved position has been applied and
|
||||||
|
// the DOM has painted (OnDomReady → domReady) — so the window appears
|
||||||
|
// already at its final size and position, with no post-launch jump.
|
||||||
|
StartHidden: true,
|
||||||
AssetServer: &assetserver.Options{
|
AssetServer: &assetserver.Options{
|
||||||
Assets: assets,
|
Assets: assets,
|
||||||
},
|
},
|
||||||
BackgroundColour: &options.RGBA{R: 250, G: 250, B: 249, A: 1},
|
BackgroundColour: &options.RGBA{R: 250, G: 250, B: 249, A: 1},
|
||||||
OnStartup: app.startup,
|
OnStartup: app.startup,
|
||||||
|
OnDomReady: app.domReady,
|
||||||
OnBeforeClose: app.beforeClose,
|
OnBeforeClose: app.beforeClose,
|
||||||
OnShutdown: app.shutdown,
|
OnShutdown: app.shutdown,
|
||||||
Bind: []interface{}{
|
Bind: []interface{}{
|
||||||
|
|||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Relay auto-control: drives the Station Control relay boards automatically from
|
||||||
|
// the rig's current frequency / band — the equivalent of PstRotator's "Automatic
|
||||||
|
// Control". Each relay carries at most one rule:
|
||||||
|
// - "freq": ON while the frequency is inside [lo,hi] kHz, OFF otherwise;
|
||||||
|
// - "band": ON while the current band is one of the listed bands, OFF otherwise;
|
||||||
|
// - "off"/empty: not managed (left to manual control).
|
||||||
|
//
|
||||||
|
// Evaluated on every CAT frequency/band change. A relay is only switched when its
|
||||||
|
// desired state actually changed since the last apply, so a slow relay board isn't
|
||||||
|
// hammered while you tune within the same range.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyRelayAuto = "relayauto.config"
|
||||||
|
|
||||||
|
// RelayAutoRule is one relay's automatic-control rule.
|
||||||
|
type RelayAutoRule struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
Relay int `json:"relay"` // 1-based
|
||||||
|
Mode string `json:"mode"` // "off" | "freq" | "band"
|
||||||
|
FreqLoKHz float64 `json:"freq_lo_khz"`
|
||||||
|
FreqHiKHz float64 `json:"freq_hi_khz"`
|
||||||
|
Bands []string `json:"bands"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelayAutoConfig is the whole auto-control setup: a master switch + the rules.
|
||||||
|
type RelayAutoConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Rules []RelayAutoRule `json:"rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRelayAuto returns the relay auto-control configuration for the settings UI.
|
||||||
|
func (a *App) GetRelayAuto() RelayAutoConfig {
|
||||||
|
var cfg RelayAutoConfig
|
||||||
|
if a.settings == nil {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
s, _ := a.settings.GetGlobal(a.ctx, keyRelayAuto)
|
||||||
|
if strings.TrimSpace(s) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(s), &cfg)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRelayAuto persists the configuration and applies it immediately from the
|
||||||
|
// current rig state, so toggling a rule takes effect without waiting for the next
|
||||||
|
// frequency change.
|
||||||
|
func (a *App) SaveRelayAuto(cfg RelayAutoConfig) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.settings.SetGlobal(a.ctx, keyRelayAuto, string(b)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.relayAutoOn.Store(cfg.Enabled) // keep the CAT hot-path flag in sync
|
||||||
|
// Re-apply from the live frequency so a just-changed rule takes hold now. Also
|
||||||
|
// forget the last-applied cache so a rule the user just switched to "off" and
|
||||||
|
// back gets re-sent even if the value is unchanged.
|
||||||
|
a.relayAutoMu.Lock()
|
||||||
|
a.relayAutoLast = map[string]bool{}
|
||||||
|
a.relayAutoMu.Unlock()
|
||||||
|
if a.cat != nil {
|
||||||
|
st := a.cat.State()
|
||||||
|
go a.applyRelayAuto(st.FreqHz, st.Band)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func relayAutoKey(dev string, relay int) string { return dev + "|" + strconv.Itoa(relay) }
|
||||||
|
|
||||||
|
func bandInList(bands []string, band string) bool {
|
||||||
|
band = strings.ToLower(strings.TrimSpace(band))
|
||||||
|
if band == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, b := range bands {
|
||||||
|
if strings.ToLower(strings.TrimSpace(b)) == band {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// relayAction is one relay's computed desired state for this evaluation.
|
||||||
|
type relayAction struct {
|
||||||
|
dev string
|
||||||
|
relay int
|
||||||
|
want bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyRelayAuto evaluates every rule against the current frequency/band and
|
||||||
|
// switches only the relays that are NOT already in the wanted position. Two things
|
||||||
|
// it deliberately does NOT do, which used to make the relay clunk on every
|
||||||
|
// launch/close:
|
||||||
|
// - Never acts on an UNKNOWN frequency/band. When the CAT disconnects (app close)
|
||||||
|
// the frequency drops to 0; reading that as "out of range" and switching the
|
||||||
|
// relay off — then back on at the next launch — was the whole bug.
|
||||||
|
// - Never commands a relay already in the right position: on the first evaluation
|
||||||
|
// after launch/save it reads the boards' LIVE state, so a relay that's already
|
||||||
|
// correct is left untouched instead of being re-sent.
|
||||||
|
func (a *App) applyRelayAuto(freqHz int64, band string) {
|
||||||
|
a.relayAutoMu.Lock()
|
||||||
|
defer a.relayAutoMu.Unlock()
|
||||||
|
|
||||||
|
cfg := a.GetRelayAuto()
|
||||||
|
if !cfg.Enabled || len(cfg.Rules) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.relayAutoLast == nil {
|
||||||
|
a.relayAutoLast = map[string]bool{}
|
||||||
|
}
|
||||||
|
khz := float64(freqHz) / 1000.0
|
||||||
|
band = strings.TrimSpace(band)
|
||||||
|
|
||||||
|
// Compute desired states, skipping rules whose input is unknown right now.
|
||||||
|
var acts []relayAction
|
||||||
|
needLive := false
|
||||||
|
for _, r := range cfg.Rules {
|
||||||
|
if r.Relay < 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var want bool
|
||||||
|
switch r.Mode {
|
||||||
|
case "freq":
|
||||||
|
if freqHz <= 0 {
|
||||||
|
continue // no known frequency (CAT off/closing) → leave the relay as-is
|
||||||
|
}
|
||||||
|
if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 {
|
||||||
|
continue // unconfigured range
|
||||||
|
}
|
||||||
|
lo, hi := r.FreqLoKHz, r.FreqHiKHz
|
||||||
|
if hi < lo {
|
||||||
|
lo, hi = hi, lo
|
||||||
|
}
|
||||||
|
want = khz >= lo && khz <= hi
|
||||||
|
case "band":
|
||||||
|
if band == "" {
|
||||||
|
continue // no known band → leave the relay as-is
|
||||||
|
}
|
||||||
|
if len(r.Bands) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
want = bandInList(r.Bands, band)
|
||||||
|
default:
|
||||||
|
continue // "off"/empty → not managed
|
||||||
|
}
|
||||||
|
acts = append(acts, relayAction{r.DeviceID, r.Relay, want})
|
||||||
|
if _, ok := a.relayAutoLast[relayAutoKey(r.DeviceID, r.Relay)]; !ok {
|
||||||
|
needLive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(acts) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// First evaluation after launch/save: read the boards' LIVE relay states once
|
||||||
|
// so we don't re-command a relay that's already in the wanted position.
|
||||||
|
var live map[string]bool
|
||||||
|
if needLive {
|
||||||
|
live = map[string]bool{}
|
||||||
|
for _, ds := range a.GetStationStatus() {
|
||||||
|
for _, rl := range ds.Relays {
|
||||||
|
live[relayAutoKey(ds.ID, rl.Number)] = rl.On
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
for _, ac := range acts {
|
||||||
|
key := relayAutoKey(ac.dev, ac.relay)
|
||||||
|
cur, known := a.relayAutoLast[key]
|
||||||
|
if !known && live != nil {
|
||||||
|
cur, known = live[key]
|
||||||
|
}
|
||||||
|
if known && cur == ac.want {
|
||||||
|
a.relayAutoLast[key] = ac.want // already in position — record it, don't switch
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := a.StationSetRelay(ac.dev, ac.relay, ac.want); err != nil {
|
||||||
|
applog.Printf("relay auto: set %s relay %d = %v failed: %v", ac.dev, ac.relay, ac.want, err)
|
||||||
|
continue // don't cache a failed write — retry next change
|
||||||
|
}
|
||||||
|
a.relayAutoLast[key] = ac.want
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed && a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "station:relay_auto", nil) // nudge the Station Control UI to re-poll
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.19.6"
|
appVersion = "0.20.6"
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,12 +23,13 @@ import (
|
|||||||
// build (the exe lives there; source stays on Gitea). Adjust the repo if needed.
|
// build (the exe lives there; source stays on Gitea). Adjust the repo if needed.
|
||||||
const updateCheckURL = "https://api.github.com/repos/GregTroar/OpsLog/releases/latest"
|
const updateCheckURL = "https://api.github.com/repos/GregTroar/OpsLog/releases/latest"
|
||||||
|
|
||||||
// UpdateInfo is the result of the startup version check.
|
// UpdateInfo is the result of the version check.
|
||||||
type UpdateInfo struct {
|
type UpdateInfo struct {
|
||||||
Current string `json:"current"` // this build's version (appVersion)
|
Current string `json:"current"` // this build's version (appVersion)
|
||||||
Latest string `json:"latest"` // newest published release, "" if unknown
|
Latest string `json:"latest"` // newest published release, "" if unknown
|
||||||
Available bool `json:"available"` // Latest > Current
|
Available bool `json:"available"` // Latest > Current
|
||||||
URL string `json:"url"` // release page to open
|
URL string `json:"url"` // release page to open (manual fallback)
|
||||||
|
DownloadURL string `json:"download_url"` // the .exe/.zip asset to auto-download, "" if none
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckForUpdate asks GitHub for the latest release and compares it to this
|
// CheckForUpdate asks GitHub for the latest release and compares it to this
|
||||||
@@ -45,6 +55,10 @@ func (a *App) CheckForUpdate() UpdateInfo {
|
|||||||
var r struct {
|
var r struct {
|
||||||
TagName string `json:"tag_name"`
|
TagName string `json:"tag_name"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
|
Assets []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"browser_download_url"`
|
||||||
|
} `json:"assets"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
||||||
return out
|
return out
|
||||||
@@ -52,8 +66,25 @@ func (a *App) CheckForUpdate() UpdateInfo {
|
|||||||
out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v")
|
out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v")
|
||||||
out.URL = r.HTMLURL
|
out.URL = r.HTMLURL
|
||||||
out.Available = versionLess(appVersion, out.Latest)
|
out.Available = versionLess(appVersion, out.Latest)
|
||||||
|
// Pick the auto-download asset: a bare Windows .exe (portable build) first,
|
||||||
|
// else a .zip we can unpack. The frontend hands this straight to
|
||||||
|
// DownloadAndApplyUpdate for a one-click in-app update.
|
||||||
|
for _, as := range r.Assets {
|
||||||
|
if strings.HasSuffix(strings.ToLower(as.Name), ".exe") {
|
||||||
|
out.DownloadURL = as.URL
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.DownloadURL == "" {
|
||||||
|
for _, as := range r.Assets {
|
||||||
|
if strings.HasSuffix(strings.ToLower(as.Name), ".zip") {
|
||||||
|
out.DownloadURL = as.URL
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if out.Available {
|
if out.Available {
|
||||||
applog.Printf("update: newer version available — current=%s latest=%s", appVersion, out.Latest)
|
applog.Printf("update: newer version available — current=%s latest=%s asset=%q", appVersion, out.Latest, out.DownloadURL)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -82,6 +113,176 @@ func versionLess(a, b string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DownloadAndApplyUpdate downloads the new build, swaps it in for the running exe
|
||||||
|
// and relaunches — the fully in-app update. Progress is emitted on "update:progress"
|
||||||
|
// (0-100) so the UI can show a bar. On success it never returns normally: it starts
|
||||||
|
// the new process and quits this one.
|
||||||
|
func (a *App) DownloadAndApplyUpdate(url string) error {
|
||||||
|
if strings.TrimSpace(url) == "" {
|
||||||
|
return fmt.Errorf("no download URL")
|
||||||
|
}
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("locate executable: %w", err)
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(exe)
|
||||||
|
|
||||||
|
// Download to a temp file next to the exe (same volume, so the rename-swap is
|
||||||
|
// atomic and can't fail across drives).
|
||||||
|
tmp := filepath.Join(dir, ".opslog-update.download")
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
if err := a.downloadWithProgress(url, tmp); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return fmt.Errorf("download: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The asset is either the bare exe or a zip holding it. Resolve to the new exe.
|
||||||
|
newExe := tmp
|
||||||
|
if strings.HasSuffix(strings.ToLower(url), ".zip") {
|
||||||
|
extracted, xerr := extractExeFromZip(tmp, dir)
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
if xerr != nil {
|
||||||
|
return fmt.Errorf("unpack: %w", xerr)
|
||||||
|
}
|
||||||
|
newExe = extracted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap: rename the running exe out of the way (Windows allows renaming a
|
||||||
|
// running image), move the new one into its place, then relaunch. Roll back if
|
||||||
|
// the second rename fails so we never end up with no exe.
|
||||||
|
oldExe := exe + ".old"
|
||||||
|
_ = os.Remove(oldExe)
|
||||||
|
if err := os.Rename(exe, oldExe); err != nil {
|
||||||
|
_ = os.Remove(newExe)
|
||||||
|
return fmt.Errorf("stage current exe: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(newExe, exe); err != nil {
|
||||||
|
_ = os.Rename(oldExe, exe) // roll back
|
||||||
|
return fmt.Errorf("install new exe: %w", err)
|
||||||
|
}
|
||||||
|
// Clear the "downloaded from the internet" mark (NTFS Zone.Identifier stream).
|
||||||
|
// Otherwise Windows SmartScreen wants to prompt "are you sure you want to open
|
||||||
|
// this?" — but since we launch the exe programmatically that prompt never shows,
|
||||||
|
// and the launch is silently blocked. This is exactly why the relaunch failed.
|
||||||
|
_ = os.Remove(exe + ":Zone.Identifier")
|
||||||
|
applog.Printf("update: installed new exe, scheduling relaunch")
|
||||||
|
|
||||||
|
// Relaunch via a detached, hidden PowerShell that WAITS for this process to exit
|
||||||
|
// (so the single-instance mutex is free) and THEN starts the new exe. Launching
|
||||||
|
// the new exe directly while we're still alive raced the mutex and often left
|
||||||
|
// nothing running; waiting for our own exit first makes the restart reliable,
|
||||||
|
// and the launcher outlives us.
|
||||||
|
quoted := strings.ReplaceAll(exe, "'", "''")
|
||||||
|
ps := fmt.Sprintf(
|
||||||
|
"Wait-Process -Id %d -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; Start-Process -FilePath '%s' -ArgumentList '--post-update'",
|
||||||
|
os.Getpid(), quoted)
|
||||||
|
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("schedule relaunch: %w", err)
|
||||||
|
}
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.Quit(a.ctx)
|
||||||
|
} else {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadWithProgress streams url into dest, emitting "update:progress" (0-100).
|
||||||
|
func (a *App) downloadWithProgress(url, dest string) error {
|
||||||
|
client := &http.Client{Timeout: 10 * time.Minute}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
f, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
total := resp.ContentLength
|
||||||
|
var read int64
|
||||||
|
last := -1
|
||||||
|
buf := make([]byte, 64*1024)
|
||||||
|
emit := func(pct int) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "update:progress", pct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(0)
|
||||||
|
for {
|
||||||
|
n, rerr := resp.Body.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if _, werr := f.Write(buf[:n]); werr != nil {
|
||||||
|
return werr
|
||||||
|
}
|
||||||
|
read += int64(n)
|
||||||
|
if total > 0 {
|
||||||
|
if pct := int(read * 100 / total); pct != last {
|
||||||
|
last = pct
|
||||||
|
emit(pct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rerr == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
return rerr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(100)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractExeFromZip unpacks the first *.exe found in the zip into dir and returns
|
||||||
|
// its path.
|
||||||
|
func extractExeFromZip(zipPath, dir string) (string, error) {
|
||||||
|
zr, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
for _, zf := range zr.File {
|
||||||
|
if !strings.HasSuffix(strings.ToLower(zf.Name), ".exe") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rc, err := zf.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out := filepath.Join(dir, ".opslog-update.exe")
|
||||||
|
f, err := os.Create(out)
|
||||||
|
if err != nil {
|
||||||
|
rc.Close()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, cerr := io.Copy(f, rc)
|
||||||
|
rc.Close()
|
||||||
|
f.Close()
|
||||||
|
if cerr != nil {
|
||||||
|
return "", cerr
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no .exe inside the archive")
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupOldUpdateBinary removes the previous exe left behind by a self-update
|
||||||
|
// (exe + ".old"). Called at startup after a --post-update relaunch. Best-effort:
|
||||||
|
// the file may still be briefly locked, in which case the next launch gets it.
|
||||||
|
func cleanupOldUpdateBinary() {
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
_ = os.Remove(exe + ".old")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// leadingInt parses the leading digits of s (e.g. "2beta" → 2), 0 if none.
|
// leadingInt parses the leading digits of s (e.g. "2beta" → 2), 0 if none.
|
||||||
func leadingInt(s string) int {
|
func leadingInt(s string) int {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
|
|||||||
Reference in New Issue
Block a user