Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3704dbdd99 | ||
|
|
3cdf16b501 | ||
|
|
9a72afd467 | ||
|
|
e2bfe73bdb | ||
|
|
6e2e2cc3aa | ||
|
|
75d11a4069 | ||
|
|
cfe5d6f2a7 | ||
|
|
78c289d681 | ||
|
|
761f554ac0 | ||
|
|
4c1b4fd1a8 | ||
|
|
f2cc4f11cd | ||
|
|
faf084ddfc | ||
|
|
01dcd91253 | ||
|
|
3d603d34aa | ||
|
|
3d1602c4bd | ||
|
|
a10fb2413a | ||
|
|
bd8ca2e9de | ||
|
|
df56391eb8 | ||
|
|
82ce3353c4 | ||
|
|
20dc3e83a6 | ||
|
|
62cdabe114 | ||
|
|
9bfb44925b | ||
|
|
fb1af9b6a2 | ||
|
|
57a9bed145 | ||
|
|
fbd323af26 | ||
|
|
689d1cc902 | ||
|
|
67602fd485 | ||
|
|
2de5bce998 | ||
|
|
f0959c796d | ||
|
|
16e64fd7b7 | ||
|
|
7e696a72bb | ||
|
|
9af2732314 | ||
|
|
f82d92565f | ||
|
|
d0f25aea9b | ||
|
|
f91b83ee12 | ||
|
|
a554257d5f | ||
|
|
a20d9a0b76 | ||
|
|
92ee8aadde | ||
|
|
61c6666a4c | ||
|
|
9f5c482667 | ||
|
|
b9ea077a64 | ||
|
|
a48fa0b2e7 | ||
|
|
821883dfd3 | ||
|
|
ef087492cc | ||
|
|
98f11ee3d0 | ||
|
|
058f164ab6 | ||
|
|
6c47c27775 | ||
|
|
7410fa4825 | ||
|
|
246d9d1b6a | ||
|
|
d12cfe94cd | ||
|
|
ede81d3926 | ||
|
|
f27faa81a6 | ||
|
|
69e6c20b97 | ||
|
|
d93c40ebb3 | ||
|
|
dfe3afbf2d | ||
|
|
bb3542c920 | ||
|
|
a966abd9df | ||
|
|
95482ec1d8 | ||
|
|
500fe8bfec | ||
|
|
87ff0a9e16 | ||
|
|
18f9b915c5 | ||
|
|
b3fdd0b7fa | ||
|
|
9846354bf9 | ||
|
|
3da1d71323 | ||
|
|
72f692aa04 | ||
|
|
f6cd6e999a | ||
|
|
296a4a55c0 | ||
|
|
acf73c73c7 | ||
|
|
5777c119cb | ||
|
|
85bf0da006 | ||
|
|
b6ea07e3a3 | ||
|
|
549adc8c0e | ||
|
|
0e1e7d9f3c | ||
|
|
3dd428d748 | ||
|
|
c62d992ad6 | ||
|
|
a83acb0f9a | ||
|
|
d8b7b86eb7 | ||
|
|
3e1c36d186 | ||
|
|
6255a9f218 | ||
|
|
599621a998 | ||
|
|
d7bcf21203 | ||
|
|
589e0b9b6a | ||
|
|
c21d6cc23a | ||
|
|
ae21ddb9d7 | ||
|
|
a4623e9ea3 | ||
|
|
06e34372ed | ||
|
|
d23c4c4037 | ||
|
|
d4bfd30636 | ||
|
|
258fa717f1 | ||
|
|
aa995c250c | ||
|
|
72fee7a090 | ||
|
|
ed67ed7fe3 | ||
|
|
fddb3c45c4 | ||
|
|
da1f3eb2bd | ||
|
|
5e0bb6e68e | ||
|
|
2b61f43780 | ||
|
|
2b66e5bc7f | ||
|
|
84b06ed47b | ||
|
|
39bd1ff414 | ||
|
|
6d54100bc9 | ||
|
|
4fe5811fd2 | ||
|
|
d42e6669e1 | ||
|
|
c14353a399 | ||
|
|
734931cf54 | ||
|
|
cad4ec4b72 | ||
|
|
5623b860c2 | ||
|
|
3e22c4d1a3 | ||
|
|
d90f953df4 | ||
|
|
e4217da010 | ||
|
|
c5ac945de0 | ||
|
|
eb40991482 | ||
|
|
781cf636ea | ||
|
|
62b6fe0a3a | ||
|
|
bd2a8524dc | ||
|
|
4afd7dda90 | ||
|
|
ed930667a1 | ||
|
|
311479c52f | ||
|
|
b25efabab8 | ||
|
|
a93f52d2b9 | ||
|
|
c9f7279a01 | ||
|
|
2560fced87 | ||
|
|
1718bf6f33 | ||
|
|
c4c5db3921 | ||
|
|
a1ceea978b | ||
|
|
f4956a63bb | ||
|
|
5a4ad800b3 | ||
|
|
e152ef0ee0 | ||
|
|
b6465ddc9d | ||
|
|
961357474d | ||
|
|
702ca4f0c9 | ||
|
|
93b4964b3b | ||
|
|
14c57035e1 | ||
|
|
7a7fa62af7 | ||
|
|
6ee4c430c7 | ||
|
|
0df6370c59 | ||
|
|
fa94657619 | ||
|
|
f5b74856c6 | ||
|
|
d42fdab85c | ||
|
|
83a8708d60 | ||
|
|
c0671bfe2e | ||
|
|
ec0cb95de9 | ||
|
|
9d691343cf | ||
|
|
c6088bb2de | ||
|
|
069728ac68 | ||
|
|
f221c0ee1e | ||
|
|
013492adb0 | ||
|
|
3a87b58ba3 | ||
|
|
e6b8a17772 | ||
|
|
802c6e8c43 | ||
|
|
f82631690a | ||
|
|
3b58e39eec | ||
|
|
45e3f3edb8 | ||
|
|
c931d8a762 | ||
|
|
eb271e8f20 | ||
|
|
e2d2485703 | ||
|
|
712a124081 | ||
|
|
08a932c9c1 | ||
|
|
fee2303625 | ||
|
|
4d5c5c3eb3 | ||
|
|
d322ea9a07 | ||
|
|
75c02ea2a0 | ||
|
|
ef84b04c99 | ||
|
|
fbd6cf400f | ||
|
|
b16cb973f2 | ||
|
|
da886de189 | ||
|
|
7759e09b4e | ||
|
|
a8990ff29d | ||
|
|
e3b810b4ff | ||
|
|
337392bb6d |
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// keyAwardsTracked holds the PER-PROFILE list of award codes the operator wants
|
||||
// to follow (JSON array of award.Def.Code). Award definitions themselves are
|
||||
// global (keyAwardDefs, shared across profiles), but WHICH awards a station
|
||||
// tracks is a per-profile choice — a DX profile follows DXCC/WPX, a POTA profile
|
||||
// follows POTA/WWFF. An empty/unset list means "track them all" so the Awards
|
||||
// tab is never blank before the operator has picked anything.
|
||||
const keyAwardsTracked = "awards.tracked"
|
||||
|
||||
// GetTrackedAwards returns the active profile's followed award codes. An empty
|
||||
// slice means the operator has not narrowed the list — the Awards tab then shows
|
||||
// every award.
|
||||
func (a *App) GetTrackedAwards() ([]string, error) {
|
||||
out := []string{}
|
||||
if a.settings == nil {
|
||||
return out, nil
|
||||
}
|
||||
s, _ := a.settings.Get(a.ctx, keyAwardsTracked)
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return out, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveTrackedAwards persists the followed award codes for the active profile and
|
||||
// notifies the Awards tab to re-filter its list. Codes are stored verbatim; the
|
||||
// Awards tab intersects them with the live award definitions, so a code that no
|
||||
// longer exists is simply ignored (not an error).
|
||||
func (a *App) SaveTrackedAwards(codes []string) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if codes == nil {
|
||||
codes = []string{}
|
||||
}
|
||||
b, err := json.Marshal(codes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyAwardsTracked, string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
wruntime.EventsEmit(a.ctx, "awards:tracked-changed")
|
||||
return nil
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// deniedCallHashes lists station callsigns not permitted to run this build,
|
||||
// stored as SHA-256 hex of the base call so the calls themselves appear nowhere
|
||||
// in the source or the compiled binary. Enforcement is best-effort by design —
|
||||
// the call is entered by the operator and can be changed — so this only turns
|
||||
// away straightforward use, not a determined one.
|
||||
var deniedCallHashes = map[string]struct{}{
|
||||
"2282a88b3e5e4eebc6b8174d005bb46d32025758b7741bdcf34f2b3621c02205": {},
|
||||
"0ee09fe60817a2a4982f5e5b14a60b8dbb11cff55b3d36661213c4cbdd0933ea": {},
|
||||
"46fb61e71afb40627cb6493a6a59c9d4d0231ee3cad1a2bf3fcc4cdfce36fabc": {},
|
||||
}
|
||||
|
||||
// callDenied reports whether a callsign is on deniedCallHashes. The call is
|
||||
// reduced to its base form (upper-cased, portable prefix/suffix dropped) the
|
||||
// same way extsvc does, so F4XYZ/P matches F4XYZ.
|
||||
func callDenied(call string) bool {
|
||||
base := denyBaseCall(call)
|
||||
if base == "" {
|
||||
return false
|
||||
}
|
||||
sum := sha256.Sum256([]byte(base))
|
||||
_, bad := deniedCallHashes[hex.EncodeToString(sum[:])]
|
||||
return bad
|
||||
}
|
||||
|
||||
// denyBaseCall mirrors extsvc.baseCall: for a slashed form it returns the
|
||||
// longest token (the real call), otherwise the call itself, upper-cased.
|
||||
func denyBaseCall(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
if !strings.Contains(s, "/") {
|
||||
return s
|
||||
}
|
||||
best := ""
|
||||
for _, part := range strings.Split(s, "/") {
|
||||
if len(part) > len(best) {
|
||||
best = part
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// enforceCallGate exits the process silently when any of the supplied callsigns
|
||||
// is denied. Called once at startup, after the profiles are loaded: the first
|
||||
// launch is where the operator enters and saves the call, so a denied build
|
||||
// simply does not come back up on the next launch. No window, no message.
|
||||
func enforceCallGate(calls ...string) {
|
||||
for _, c := range calls {
|
||||
if callDenied(c) {
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
+18902
File diff suppressed because it is too large
Load Diff
+392
@@ -1,4 +1,396 @@
|
||||
[
|
||||
{
|
||||
"version": "0.23.8",
|
||||
"date": "",
|
||||
"en": [
|
||||
"E-mail: the QSO recording e-mail (subject and body) is now editable in Settings → E-mail, like the QSL card e-mail — with the {CALL} {DATE} {BAND} {MODE} {MYCALL} variables.",
|
||||
"Backup fix: the backup now saves your CONTACTS (the logbook), not just the settings. Once the logbook was split into its own file, the backup kept snapshotting the settings database and silently missed the QSOs. It now writes the log to opslog-*.db and the configuration separately to opslogcfg-*.db (MySQL logs still export to ADIF).",
|
||||
"Performance: much lower CPU and memory on a busy cluster (RBN and other high-volume feeds). The Cluster tab was re-scanning the entire logbook for every batch of spots (~20×/second) and its spot-status cache grew without limit — on a firehose that could climb to gigabytes of RAM and peg a CPU. The worked-index is now cached (rebuilt only when you log a QSO) and the status cache is bounded to the spots actually shown."
|
||||
],
|
||||
"fr": [
|
||||
"E-mail : le texte de l'e-mail d'enregistrement QSO (objet et corps) est désormais modifiable dans Réglages → E-mail, comme l'e-mail de carte QSL — avec les variables {CALL} {DATE} {BAND} {MODE} {MYCALL}.",
|
||||
"Correction sauvegarde : la sauvegarde enregistre désormais tes CONTACTS (le journal), et plus seulement les réglages. Depuis que le journal a été séparé dans son propre fichier, la sauvegarde continuait à copier la base des réglages et oubliait les QSO. Elle écrit maintenant le log dans opslog-*.db et la configuration à part dans opslogcfg-*.db (les logs MySQL restent exportés en ADIF).",
|
||||
"Performances : CPU et mémoire nettement réduits sur un cluster chargé (RBN et autres flux à fort volume). L'onglet Cluster rescannait tout le journal à chaque lot de spots (~20×/seconde) et son cache de statuts grossissait sans limite — sur un flux intense cela pouvait atteindre des gigaoctets de RAM et saturer un cœur. L'index des contacts est désormais mis en cache (reconstruit seulement quand tu logues un QSO) et le cache de statuts est borné aux spots réellement affichés."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.23.7",
|
||||
"date": "",
|
||||
"en": [
|
||||
"QSO edit: added the QRZ ↗ button next to the callsign, like the main entry form — one click opens that station's qrz.com profile.",
|
||||
"PowerGenius XL: the fan mode (Standard / Contest / Broadcast) changes on the amp again. A previous fix dropped the \"setup\" prefix the amp requires (\"setup fanmode=…\"), so the amp rejected the command and the mode snapped straight back. The correct command is restored.",
|
||||
"Performance: fixed a slowdown introduced in 0.23.6. To dim the \"represents nothing\" spots, the DX Cluster grid was redrawing EVERY row on each spot-status update, which pegged the CPU on a busy cluster (some PCs became sluggish). The dimming now updates with a light per-cell refresh instead — same look, no more churn.",
|
||||
"DX Cluster: after you log a QSO, the spots update — a callsign, entity, POTA, prefix or band/mode slot you just worked stops showing its NEW badge, instead of staying \"new\" until a restart. Kept fast: it re-evaluates only while the cluster is actually on screen (nothing runs otherwise — a QSO logged while it's hidden refreshes once when you next open it), and it's debounced so a quick run doesn't re-scan on every QSO.",
|
||||
"Station Control relays (WebSwitch / KMTronic / Dingtian): the Host field now accepts a full URL, so you can reach a network relay board through an HTTPS reverse proxy — e.g. https://relay.yourdomain.com.",
|
||||
"Stats slot drill-down: the pop-up listing the QSOs behind a band/mode square looks tidier (banded rows, cleaner header), and you can now click a callsign in it to open that QSO for editing.",
|
||||
"DCU-1 rotor support: OpsLog can now steer controllers that speak the Hy-Gain DCU-1 protocol (RotorCard DXA for Yaesu DXA rotors, Idiom Press Rotor-EZ, Green Heron) over their COM port or a serial-over-IP bridge.",
|
||||
"Motorized antenna: Settings → Antenna now has a Covered bands selector (40 m–6 m) instead of a min/max range, for both the Ultrabeam and the SteppIR. Tick only the bands your antenna does — untick one you can't (e.g. 30 m without its extension) while keeping the rest — and OpsLog leaves the antenna put on every other band. This overrides an unreliable controller, so 80 m no longer clamps the elements down to 30 m. Existing range settings migrate automatically.",
|
||||
"Motorized antenna: on a band you did not tick in Covered bands, OpsLog no longer inhibits FlexRadio transmit for \"antenna moving\". Un-ticking a band now means hands off completely — no tuning and no TX block — so working it on another antenna is never gagged.",
|
||||
"Awards: a new Settings → Awards picker (under User configuration) lets you choose which awards to follow — a two-column list, all awards on the left, the ones you track on the right. The Awards tab then shows only those you follow (per profile; leave the right side empty to show them all).",
|
||||
"Kenwood/Elecraft CAT: the link no longer drops while the rig is transmitting. A K3 (and other Kenwood-dialect rigs) answers \"?;\" to a status poll during transmit; OpsLog used to read that as \"rig doesn't support IF\" and disconnect — which tore down the shared CAT that WSJT-X / JTDX key through, so the radio wouldn't transmit properly. OpsLog now pauses status polling while PTT is held and keeps the link up.",
|
||||
"New award: Russian District Award (RDA) — tracks the 2660 Russian districts, worked and confirmed per band."
|
||||
],
|
||||
"fr": [
|
||||
"Édition de QSO : ajout du bouton QRZ ↗ à côté de l'indicatif, comme dans le formulaire de saisie — un clic ouvre le profil qrz.com de la station.",
|
||||
"PowerGenius XL : le mode ventilateur (Standard / Contest / Broadcast) change de nouveau sur l'ampli. Un correctif précédent avait retiré le préfixe « setup » exigé par l'ampli (« setup fanmode=… »), qui rejetait donc la commande et le mode revenait aussitôt en arrière. La bonne commande est rétablie.",
|
||||
"Performance : correction d'un ralentissement apparu en 0.23.6. Pour atténuer les spots « qui ne représentent rien », la grille du DX Cluster redessinait TOUTES les lignes à chaque mise à jour de statut, ce qui saturait le CPU sur un cluster actif (des PC devenaient lents). L'atténuation se met désormais à jour via un rafraîchissement léger par cellule — même rendu, sans le brassage.",
|
||||
"DX Cluster : après avoir loggué un QSO, les spots se mettent à jour — un indicatif, une entité, un POTA, un préfixe ou un slot bande/mode que vous venez de contacter cesse d'afficher son badge NEW, au lieu de rester « new » jusqu'au redémarrage. Reste rapide : la réévaluation n'a lieu que si le cluster est affiché (sinon rien ne tourne — un QSO loggué cluster caché se rafraîchit une fois à sa réouverture), et c'est débouncé pour ne pas re-scanner à chaque QSO en série.",
|
||||
"Relais du Contrôle station (WebSwitch / KMTronic / Dingtian) : le champ Hôte accepte désormais une URL complète ex. https://relais.tondomaine.com derrière Nginx Proxy Manager. Avant, OpsLog forçait http://<hôte>, donc une carte sur le LAN derrière un proxy (quand vos 80/443 vont déjà ailleurs) était injoignable de l'extérieur.",
|
||||
"Détail d’un slot Stats : la fenêtre listant les QSO derrière une case bande/mode est plus soignée (lignes alternées, en-tête plus propre), et vous pouvez maintenant cliquer un indicatif pour ouvrir ce QSO en édition.",
|
||||
"Prise en charge des rotors DCU-1 : OpsLog pilote désormais les contrôleurs parlant le protocole Hy-Gain DCU-1 (RotorCard DXA pour rotors Yaesu DXA, Idiom Press Rotor-EZ, Green Heron) via leur port COM ou un pont série-sur-IP.",
|
||||
"Antenne motorisée : Réglages → Antenne propose désormais un sélecteur Bandes couvertes (40 m–6 m) au lieu d'une plage min/max, pour l'Ultrabeam comme pour la SteppIR. Coche seulement les bandes que fait ton antenne — décoche celle que tu ne fais pas (p. ex. le 30 m sans son extension) en gardant les autres — et OpsLog laisse l'antenne en place sur toutes les autres. Ceci prime sur un contrôleur peu fiable : le 80 m ne replie plus les éléments sur le 30 m. Les anciens réglages de plage sont migrés automatiquement.",
|
||||
"Antenne motorisée : sur une bande non cochée dans Bandes couvertes, OpsLog n'inhibe plus l'émission du FlexRadio pour « antenne en mouvement ». Décocher une bande signifie désormais ne plus y toucher du tout — ni accord ni blocage TX — pour ne jamais couper le trafic sur une autre antenne.",
|
||||
"Diplômes : un nouveau sélecteur Réglages → Diplômes (dans Configuration utilisateur) permet de choisir les diplômes à suivre — une liste à deux colonnes, tous les diplômes à gauche, ceux que tu suis à droite. L'onglet Awards n'affiche alors que ceux-là (par profil ; laisse la colonne de droite vide pour tous les afficher).",
|
||||
"CAT Kenwood/Elecraft : le lien ne tombe plus pendant que le rig émet. Un K3 (et d'autres rigs en dialecte Kenwood) répond \"?;\" à une interrogation d'état pendant l'émission ; OpsLog le prenait pour \"le rig ne supporte pas IF\" et se déconnectait — ce qui cassait le CAT partagé que WSJT-X / JTDX utilisent pour passer en émission, d'où l'absence d'émission. OpsLog suspend désormais l'interrogation d'état tant que le PTT est actif et garde le lien.",
|
||||
"Nouveau diplôme : Russian District Award (RDA) — suit les 2660 districts russes, travaillés et confirmés par bande."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.23.6",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Log viewer: the window now keeps twice as much history (512 KB instead of 256 KB, ~3200 lines). During a busy trace the oldest lines used to scroll out of the buffer while you were still reading them; the larger window holds them.",
|
||||
"Ultrabeam over a remote link: fixed intermittent connection drops, phantom frequency jumps and wrong element-length readings. On a slow link a command that timed out left its late reply in the stream, and the next command read it as its own — crossing a status query with an element-length one, for instance. The stream is now flushed of any stale reply before each command, keeping the exchange in step.",
|
||||
"Ultrabeam: the 'moving' indicator and the Flex TX-inhibit now react the instant you click a band or pattern, instead of up to a status poll (~2 s) later. A commanded move reports motion immediately; the real motor state takes over once the next poll reads it.",
|
||||
"CAT sharing (FT8): JTDX/WSJT-X transmit correctly again through OpsLog's shared rig. get_ptt now echoes the PTT state the client last commanded — clients poll it DURING transmit to confirm the rig is keyed, and OpsLog always answering 'receiving' made them give up and drop the over after a second or two. The 0.23.5 'Fake It' anti-drift tweak was also reverted (it had made this total — no transmit at all); the small dial creep in 'Fake It' split can return, and JTDX's 'Split Operation: None' avoids it.",
|
||||
"CAT settings: trimmed the long help text under the Kenwood and CAT-sharing fields down to the essentials — less clutter.",
|
||||
"Entry form: fixed two ways a stray QSO could sneak in. Pressing Enter in the TX/RX frequency field now only tunes the radio — it was also submitting the contact. And a bare number with no letter (JTDX/WSJT-X sometimes broadcasts \"1\" as the DX call) is no longer accepted into the callsign field, where it could get logged as callsign \"1\".",
|
||||
"SPE amplifier: switching the amp off in OpsLog now shows \"off\" straight away and lets you switch it back on without restarting. The already-open connection kept reading as connected after the off keystroke, so the panel stayed \"on\" and the ON button did nothing until a restart. OpsLog now drops the link on power-off, exactly as a fresh start would — the ON button's RTS/DTR wake then works.",
|
||||
"Alt+W clears the QSO entry, the shortcut used by most other loggers. Unlike Esc it always works, even when the CW keyer has Esc reserved.",
|
||||
"The Grid box no longer pops outside the QSO entry panel when the window is narrowed — it wraps to its own line instead.",
|
||||
"Selecting a QSO in the Recent QSOs list now shows that station in the Stats (F1) matrix, instead of leaving it blank.",
|
||||
"Stats (F1): click any coloured band/mode square to list the contacts behind it.",
|
||||
"Awards: the DXCC list now shows each entity’s main prefix (XE, DL, F…) in its own sortable, searchable column.",
|
||||
"DX Cluster: new option \"Already worked only on the same slot\" (Settings → DX Cluster). With it on, a spot reads \"worked\" only when you worked that callsign on the SAME band and mode — not just anywhere. It respects the digital-mode grouping (Settings → General): grouped, a 20m FT8 contact also marks a 20m FT4 spot as worked; ungrouped, FT8 and FT4 are separate slots.",
|
||||
"DX Cluster: added a WORKED status-filter chip, so you can show — or, with the other chips off, isolate — already-worked spots, not only hide them with the \"Hide worked\" checkbox.",
|
||||
"DX Cluster: spots that represent nothing — entity/band/mode already worked, the callsign not in your log, no POTA/county/prefix novelty — are now dimmed, so your eye skips them and the spots worth working stand out. What counts as \"nothing\" follows the \"same slot\" option and the digital-mode grouping.",
|
||||
"Ultrabeam: an older controller (seen over an RS232-to-Ethernet bridge) answers with an 11-byte status frame instead of 12. OpsLog rejected it as \"too short\" and reconnect-looped; it now accepts a shorter checksum-valid frame and defaults the missing tail field.",
|
||||
"Fixed a crash (React #300) that could take down the whole window when the rig briefly reported an unusual band — for example a spurious 33 cm reading from an Icom. The Band Map had a keyboard-navigation hook after its early return for unknown bands, so the number of hooks changed between renders. The hook now always runs."
|
||||
],
|
||||
"fr": [
|
||||
"Visionneuse de log : la fenêtre conserve deux fois plus d'historique (512 Ko au lieu de 256 Ko, ~3200 lignes). Lors d'une trace chargée, les plus vieilles lignes défilaient hors du buffer pendant qu'on les lisait encore ; la fenêtre agrandie les garde.",
|
||||
"Ultrabeam en remote : coupures de connexion intermittentes, sauts de fréquence fantômes et longueurs d'éléments erronées corrigés. Sur un lien lent, une commande qui expirait laissait sa réponse tardive dans le flux, et la commande suivante la lisait comme la sienne — croisant par exemple une requête de statut avec une requête de longueurs d'éléments. Le flux est désormais vidé de toute réponse périmée avant chaque commande, gardant l'échange synchronisé.",
|
||||
"Ultrabeam : l'indicateur « en mouvement » et l'inhibition d'émission Flex réagissent désormais dès que vous cliquez sur une bande ou un diagramme, au lieu d'attendre jusqu'à un poll de statut (~2 s). Un mouvement commandé signale le déplacement immédiatement ; l'état réel des moteurs prend le relais dès le poll suivant.",
|
||||
"Partage CAT (FT8) : JTDX/WSJT-X émettent de nouveau correctement via la radio partagée d'OpsLog. get_ptt renvoie désormais l'état PTT commandé en dernier par le client — les clients l'interrogent PENDANT l'émission pour confirmer que la radio est en émission, et comme OpsLog répondait toujours « réception », ils abandonnaient et coupaient l'émission au bout d'une seconde ou deux. L'ajustement anti-drift « Fake It » de la 0.23.5 a aussi été annulé (il rendait le problème total — aucune émission) ; le léger glissement du VFO en split « Fake It » peut réapparaître, et « Split Operation : None » dans JTDX l'évite.",
|
||||
"Réglages CAT : textes d'aide raccourcis sous les champs Kenwood et partage CAT — moins de fouillis.",
|
||||
"Formulaire de saisie : deux façons de logguer un QSO parasite corrigées. Appuyer sur Entrée dans le champ fréquence TX/RX ne fait plus que syntoniser la radio — cela validait aussi le contact. Et un nombre nu sans lettre (JTDX/WSJT-X diffuse parfois « 1 » comme DX call) n'est plus accepté dans le champ indicatif, où il pouvait être loggué comme indicatif « 1 ».",
|
||||
"Ampli SPE : éteindre l'ampli depuis OpsLog affiche désormais « off » tout de suite et permet de le rallumer sans redémarrer. La connexion déjà ouverte continuait d'être vue comme connectée après la touche d'extinction, le panneau restait donc « on » et le bouton ON ne faisait rien jusqu'à un redémarrage. OpsLog largue maintenant le lien à l'extinction, exactement comme un démarrage à neuf — le réveil RTS/DTR du bouton ON fonctionne alors.",
|
||||
"Alt+W efface la saisie du QSO, le raccourci utilisé par la plupart des autres logiciels. Contrairement à Échap, il fonctionne toujours, même quand Échap est réservé au manipulateur CW.",
|
||||
"Le champ Locator ne déborde plus du panneau de saisie quand la fenêtre est rétrécie — il passe à la ligne.",
|
||||
"Sélectionner un QSO dans la liste des QSO récents affiche désormais cette station dans la matrice Stats (F1), au lieu de la laisser vide.",
|
||||
"Stats (F1) : cliquer sur une case bande/mode colorée liste les contacts correspondants.",
|
||||
"Diplômes : la liste DXCC affiche le préfixe principal de chaque entité (XE, DL, F…) dans une colonne triable et cherchable.",
|
||||
"DX Cluster : nouvelle option « Déjà contacté seulement sur le même slot » (Réglages → DX Cluster). Activée, un spot n'affiche « contacté » que si vous avez contacté cet indicatif sur la MÊME bande et le MÊME mode — pas juste n'importe où. Elle respecte le groupage des modes numériques (Réglages → Général) : groupé, un contact 20m FT8 marque aussi un spot 20m FT4 comme contacté ; dégroupé, FT8 et FT4 sont des slots distincts.",
|
||||
"DX Cluster : ajout d'un filtre de statut WORKED, pour afficher — ou, en désactivant les autres, isoler — les spots déjà contactés, pas seulement les masquer avec la case « Masquer les contactés ».",
|
||||
"DX Cluster : les spots qui ne représentent rien — entité/bande/mode déjà faite, indicatif absent du journal, aucune nouveauté POTA/comté/préfixe — sont désormais atténués, pour que l'œil les ignore et que les spots à travailler ressortent. Ce qui compte comme « rien » suit l'option « même slot » et le groupage des modes numériques.",
|
||||
"Ultrabeam : un contrôleur plus ancien (vu derrière un pont RS232-Ethernet) répond avec une trame de statut de 11 octets au lieu de 12. OpsLog la rejetait comme « trop courte » et bouclait en reconnexion ; il accepte désormais une trame valide plus courte et met par défaut le champ de fin manquant.",
|
||||
"Correction d'un plantage (React #300) qui pouvait faire tomber toute la fenêtre quand la radio rapportait brièvement une bande inhabituelle — par exemple une lecture 33 cm parasite d'un Icom. La Band Map avait un hook de navigation clavier après son retour anticipé pour les bandes inconnues, changeant le nombre de hooks entre deux rendus. Le hook est désormais toujours exécuté."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.23.5",
|
||||
"date": "",
|
||||
"en": [
|
||||
"CAT sharing (Hamlib rigctld): fixed a slow frequency drift in FT8. With a digital app (JTDX/WSJT-X) sharing OpsLog's rig in \"Fake It\" split, each transmit crept the dial down and never came back. The app follows the dial by polling, and our reply lagged the last tune by a poll cycle, so right after a transmission it read the still-shifted frequency, took it for a manual QSY and adopted it. Reads now echo the last commanded frequency until the rig confirms it — the dial holds. Applies to every backend, not just Kenwood."
|
||||
],
|
||||
"fr": [
|
||||
"Partage CAT (rigctld Hamlib) : dérive lente de fréquence en FT8 corrigée. Avec une app numérique (JTDX/WSJT-X) partageant la radio d'OpsLog en split « Fake It », chaque passage en émission faisait descendre le VFO sans jamais revenir. L'app suit le VFO en l'interrogeant, et notre réponse était en retard d'un cycle sur la dernière syntonisation : juste après une émission elle lisait la fréquence encore décalée, la prenait pour un QSY manuel et l'adoptait. Les lectures renvoient désormais la dernière fréquence commandée jusqu'à confirmation de la radio — le VFO tient. Vaut pour tous les backends, pas seulement Kenwood."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.23.4",
|
||||
"date": "",
|
||||
"en": [
|
||||
"CW keyer (Kenwood/Elecraft): choosing the Kenwood/Elecraft engine now actually switches to it. A bug left the keyer on WinKeyer even though the setting showed Kenwood/Elecraft, so its CW-over-CAT never ran.",
|
||||
"Kenwood/Elecraft data mode: a new setting (Settings → CAT) chooses what a data mode (FT8/PSK…) sets on the rig — USB (default), DATA mode (MD6, for an Elecraft K3/K4), or leave the rig's mode unchanged (safest for a TS-590SG/TS-990S, whose data mode is a USB modifier set on the radio). There is no single command that fits every rig, so it's now the operator's choice.",
|
||||
"Ultrabeam over a remote link: changing the pattern (Normal / 180° / bidirectional) no longer snaps back to the old one after a few seconds. The commanded pattern is now held while the motors are still moving plus a grace window after they stop, so the slow remote status poll — which lagged behind the antenna — can't revert the display while the change is in flight.",
|
||||
"QSL Manager: closing the window — or starting another download — now cancels the one still in progress, so a slow QRZ sync no longer keeps running in the background and bleeds its log into a freshly started LoTW run. The QRZ download summary also spells out that only confirmed QSOs are acted on (e.g. \"7950 of 26164 are confirmed\") — a full QRZ logbook has many not-yet-confirmed QSOs, so nothing is lost even though the old wording read as if only a fraction came back. (A LoTW \"http 503\" is separate: the ARRL server being down — retry later.)"
|
||||
],
|
||||
"fr": [
|
||||
"Keyer CW (Kenwood/Elecraft) : choisir le moteur Kenwood/Elecraft y bascule désormais réellement. Un bug laissait le keyer sur WinKeyer alors que le réglage affichait Kenwood/Elecraft, son CW-sur-CAT ne démarrait donc jamais.",
|
||||
"Mode data Kenwood/Elecraft : un nouveau réglage (Réglages → CAT) choisit ce qu'un mode data (FT8/PSK…) règle sur la radio — USB (défaut), mode DATA (MD6, pour un Elecraft K3/K4), ou ne pas changer le mode de la radio (le plus sûr pour un TS-590SG/TS-990S dont le mode data est un modificateur d'USB réglé sur la radio). Aucune commande unique ne convient à toutes les radios, c'est donc désormais au choix de l'opérateur.",
|
||||
"Ultrabeam en remote : changer le diagramme (Normal / 180° / bidirectionnel) ne revient plus à l'ancien au bout de quelques secondes. Le diagramme commandé est maintenu tant que les moteurs bougent, plus une fenêtre de grâce après leur arrêt — le poll de statut distant, lent et en retard sur l'antenne, ne peut donc plus faire revenir l'affichage pendant que le changement est en cours.",
|
||||
"QSL Manager : fermer la fenêtre — ou lancer un autre téléchargement — annule désormais celui en cours, une synchro QRZ lente ne continue donc plus en arrière-plan à mélanger son journal à un téléchargement LoTW fraîchement lancé. Le récapitulatif QRZ indique aussi clairement que seuls les QSO confirmés sont traités (ex. « 7950 sur 26164 confirmés ») — un logbook QRZ complet contient beaucoup de QSO pas encore confirmés, rien n'est donc perdu même si l'ancien texte donnait l'impression qu'une fraction seulement était revenue. (Un « http 503 » sur LoTW est un autre sujet : serveur de l'ARRL indisponible — réessayez plus tard.)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.23.3",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Multiple rotors: Settings → Rotator is now a list, like the amplifiers — add rotors with “Add rotor”, remove with the trash icon. Mix any types (an ARCO and a Rotator Genius); a Rotator Genius set to “drives two rotors” adds a second one. The compass has a selector to pick the active rotor, which it turns, displays and points to the DX; each rotor is named, and you mark which one carries the motorized antenna (Ultrabeam/SteppIR) so the boom and reverse/bidirectional pattern paths show only for it.",
|
||||
"CAT PTT hotkey: pick a key in Settings → CAT and it keys the transmitter over CAT while OpsLog is focused — hold-to-talk or toggle (a rig with no CAT link falls back to the Audio-tab RTS/DTR method). Keys that need AltGr on your layout (e.g. \"\\\" on AZERTY) work, and each press is written to the diagnostic log.",
|
||||
"TCI spots (ExpertSDR / SunSDR): OpsLog's cluster spots now show on the panorama — the colour is sent as the decimal integer the protocol expects (a hex string was silently dropped), with a valid modulation — and clicking one fills the entry (callsign + frequency) via the CLICKED_ON_SPOT message.",
|
||||
"FlexRadio spot click: clicking one of OpsLog's spots on the SmartSDR panadapter now switches the mode too, not just the frequency.",
|
||||
"PowerGenius XL: the widget and the FlexRadio amplifier card now read forward power, drain current and temperature from the amp's own link (polled every 250 ms with peak-hold), so the meters keep working — and stay steady — over a remote link where the radio's VITA stream never reaches OpsLog. Adds a remote-access password (Settings → Amplifier) to reach the amp from outside the LAN, and fixes the fan mode (Standard/Contest/Broadcast), which was reverting to Contest.",
|
||||
"CW macro <LOGQSO>: a macro ending in the token (e.g. \"BK UR <STX> TU <LOGQSO>\") now logs the QSO once the CW before it has been sent, not the instant the macro starts — it was logging on the first keyed letter.",
|
||||
"Filter presets: the trash icon next to a saved preset deletes it again — clicking it was loading the preset instead of removing it.",
|
||||
"Kenwood/Elecraft (K3/K4): data modes (FT8/PSK/JT) now go out on USB on every band — below 10 MHz they were landing on LSB, which a K3 shows as \"DATA REV\". The frequency refreshes promptly again (the split double-check no longer runs on every poll). And a new CW keyer engine (Settings → CW Keyer → Kenwood/Elecraft) sends CW over the CAT link with the KY command — no WinKeyer and no second COM port — for a rig whose single USB port is the CAT port. Needs on-air testing on a K3."
|
||||
],
|
||||
"fr": [
|
||||
"Plusieurs rotors : Réglages → Rotator est désormais une liste, comme les amplis — ajoutez des rotors avec « Ajouter un rotor », supprimez avec la corbeille. Mélangez les types (un ARCO et un Rotator Genius) ; un Rotator Genius réglé sur « pilote deux rotors » en ajoute un second. Le compas a un sélecteur pour choisir le rotor actif, qu'il tourne, affiche et pointe vers le DX ; chaque rotor est nommé, et vous indiquez lequel porte l'antenne motorisée (Ultrabeam/SteppIR) pour que les tracés de boom et de diagramme inverse/bidirectionnel n'apparaissent que pour lui.",
|
||||
"Touche PTT CAT : choisissez une touche dans Réglages → CAT et elle passe la radio en émission via CAT quand OpsLog a le focus — maintien ou bascule (une radio sans liaison CAT retombe sur la méthode RTS/DTR de l'onglet Audio). Les touches nécessitant AltGr (ex. « \\ » en AZERTY) fonctionnent, et chaque appui est tracé dans le journal de diagnostic.",
|
||||
"Spots TCI (ExpertSDR / SunSDR) : les spots cluster d'OpsLog apparaissent sur le panorama — la couleur est envoyée en entier décimal attendu par le protocole (une chaîne hexadécimale était silencieusement rejetée), avec une modulation valide — et cliquer un spot remplit la saisie (indicatif + fréquence) via le message CLICKED_ON_SPOT.",
|
||||
"Clic sur un spot FlexRadio : cliquer un spot d'OpsLog sur le panadapter SmartSDR change désormais aussi le mode, plus seulement la fréquence.",
|
||||
"PowerGenius XL : le widget et la carte ampli du panneau FlexRadio lisent maintenant puissance directe, courant de drain et température depuis la liaison propre de l'ampli (interrogée toutes les 250 ms avec maintien de crête) — les meters continuent donc de fonctionner, et restent stables, sur une liaison distante où le flux VITA de la radio n'atteint jamais OpsLog. Ajoute un mot de passe d'accès distant (Réglages → Amplificateur) pour joindre l'ampli hors du réseau local, et corrige le mode ventilateur (Standard/Contest/Broadcast) qui repassait sur Contest.",
|
||||
"Macro CW <LOGQSO> : une macro se terminant par le token (ex. « BK UR <STX> TU <LOGQSO> ») journalise le QSO une fois le CW qui précède envoyé, et non à l'instant où la macro démarre — elle journalisait dès la première lettre manipulée.",
|
||||
"Presets de filtre : la corbeille à côté d'un preset enregistré le supprime de nouveau — le clic chargeait le preset au lieu de le retirer.",
|
||||
"Kenwood/Elecraft (K3/K4) : les modes data (FT8/PSK/JT) partent désormais en USB sur toutes les bandes — sous 10 MHz ils passaient en LSB, ce qu'un K3 affiche comme « DATA REV ». La fréquence se rafraîchit de nouveau rapidement (la double-vérification du split ne se fait plus à chaque cycle). Et un nouveau moteur de keyer (Réglages → Keyer CW → Kenwood/Elecraft) envoie le CW sur la liaison CAT avec la commande KY — sans WinKeyer ni second port COM — pour une radio dont l'unique port USB est le port CAT. À tester sur l'air avec un K3."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.23.2",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Lookup: the locator now keeps whichever is more precise. A 4-character square from QRZ or HamQTH no longer overwrites the full 6-character locator confirmed on a previous contact.",
|
||||
"LoTW badge: when ARRL's own user list has not been rebuilt for a while, the tooltip says so with its date — a station shown as \"uploaded 7 days ago\" may simply be sitting in a frozen list.",
|
||||
"FlexRadio decode spots: with two WSJT-X instances on two slices, decodes are no longer placed on the wrong panadapter, and a station heard on both is now spotted on both.",
|
||||
"DX cluster: a spot glued to the login prompt is no longer dropped. Clusters that print the prompt without a newline cost you the first spot after connecting.",
|
||||
"Help → Diagnostic log: read the log live, without leaving OpsLog. Filter the lines, and follow the end or scroll back freely.",
|
||||
"Entity stats: the DX station's sunrise and sunset (UTC) now show on the right, next to Most Wanted. Midnight sun and polar night are named as such."
|
||||
],
|
||||
"fr": [
|
||||
"Recherche : le locator garde désormais le plus précis des deux. Un carré en 4 caractères venant de QRZ ou HamQTH n'écrase plus le locator complet en 6 caractères confirmé lors d'un contact précédent.",
|
||||
"Badge LoTW : quand la liste ARRL elle-même n'a pas été régénérée depuis un moment, l'infobulle le dit avec sa date — une station annoncée « dernier envoi il y a 7 jours » peut simplement figurer dans une liste figée.",
|
||||
"Spots de décodes FlexRadio : avec deux instances WSJT-X sur deux slices, les décodes ne s'affichent plus sur le mauvais panadapter, et une station entendue sur les deux est désormais spottée sur les deux.",
|
||||
"Cluster DX : un spot collé à l'invite de connexion n'est plus perdu. Les clusters qui affichent leur invite sans retour à la ligne coûtaient le premier spot après la connexion.",
|
||||
"Aide → Journal de diagnostic : lire le journal en direct sans quitter OpsLog. Filtrage des lignes, et suivi de la fin qu'on peut relâcher pour remonter.",
|
||||
"Statistiques d'entité : le lever et le coucher du soleil de la station DX (UTC) s'affichent à droite, à côté du Most Wanted. Soleil de minuit et nuit polaire sont nommés."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.23.1",
|
||||
"date": "",
|
||||
"en": [
|
||||
"DX cluster: NEW COUNTY is green, like NEW POTA.",
|
||||
"CW: pressing ESC during a macro no longer turns the report from 599 into 59 when \"ESC clears the callsign too\" is on."
|
||||
],
|
||||
"fr": [
|
||||
"Cluster DX : NOUVEAU COMTÉ passe en vert, comme NOUVEAU POTA.",
|
||||
"CW : appuyer sur Échap pendant une macro ne fait plus passer le report de 599 à 59 quand l'option « Échap efface aussi l'indicatif » est cochée."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.23.0",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Station Control: Dingtian IOT relay boards (2 to 32 relays, LAN or WiFi) can now be added.",
|
||||
"ACOM amplifiers: the card now shows TUNE while the tuner is running, instead of an unknown state.",
|
||||
"DX cluster: the status pills are gone. The call, band and mode turn yellow when that one is new, and a callsign already in the log turns blue.",
|
||||
"Reports: starting OpsLog with the rig already in CW no longer leaves the RST fields on 59 — they now follow the mode you are actually on. (Thanks HB9HBY)",
|
||||
"DX cluster: NEW PFX marks a spot whose CQ WPX prefix has never been worked, in the status column and as a filter."
|
||||
],
|
||||
"fr": [
|
||||
"Contrôle station : les cartes relais Dingtian IOT (2 à 32 relais, LAN ou WiFi) peuvent désormais être ajoutées.",
|
||||
"Amplificateurs ACOM : la carte affiche TUNE pendant l'accord, au lieu d'un état inconnu.",
|
||||
"Cluster DX : les pastilles de statut disparaissent. L'indicatif, la bande et le mode passent en jaune quand celui-ci est nouveau, et un indicatif déjà dans le log passe en bleu.",
|
||||
"Reports : lancer OpsLog avec le poste déjà en CW ne laisse plus les RST à 59 — ils suivent maintenant le mode réellement en cours. (Merci HB9HBY)",
|
||||
"Cluster DX : NOUVEAU PFX signale un spot dont le préfixe CQ WPX n'a jamais été contacté, dans la colonne de statut et en filtre."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.9",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Awards: the CW / Phone / Digital filter now applies to the whole award — bands, counts, confirmations and the contacts behind a cell — instead of only hiding rows from the reference list.",
|
||||
"Yaesu and Kenwood: a new option lowers the DTR and RTS lines on connect, for interfaces that read either as PTT and keep the radio transmitting while OpsLog is open. Off by default — lowering them stops other interfaces transmitting at all.",
|
||||
"SPE amplifiers: the ON and OFF buttons now switch the amplifier on and off over USB.",
|
||||
"Quieter diagnostic log: no more FlexRadio meter subscriptions, per-packet WSJT-X/MSHV lines, or repeated Tuner Genius / PGXL / SPE status frames.",
|
||||
"Station Control: the cards now line up on a grid — same width, and same height across a row — and the column selector goes up to 6.",
|
||||
"New amplifier widget beside the keyers (flame icon): Operate, power ON/OFF, the SPE power level and live watts / SWR / temperature. With several amplifiers a dropdown on the icon picks the one to watch, or all of them.",
|
||||
"Icom: the meters and front-panel readings are polled only while the Icom console is on screen. They were half of the CI-V traffic at all times, which matters when the link is shared — a microHAM Router splitting one CI-V port between OpsLog and WSJT-X, for instance.",
|
||||
"Icom: the power-ON button sends a wake preamble sized for the CI-V speed. It was a fixed length that only suited 19200 baud, so a radio set faster — 115200 is the default — ignored the command.",
|
||||
"The Tuner Genius power meter now shows the peak the device itself measured, instead of whatever instant the poll happened to catch — a kilowatt transmission could read a few hundred watts. Every power meter also clears the moment the carrier drops, instead of trailing several seconds behind it."
|
||||
],
|
||||
"fr": [
|
||||
"Diplômes : le filtre CW / Phonie / Numérique s’applique désormais à tout le diplôme — bandes, comptes, confirmations et QSO derrière une case — au lieu de masquer seulement des lignes de la liste des références.",
|
||||
"Yaesu et Kenwood : une option abaisse les lignes DTR et RTS à la connexion, pour les interfaces qui les lisent comme PTT et laissent la radio en émission tant qu’OpsLog est ouvert. Désactivée par défaut — les abaisser empêche d’autres interfaces d’émettre.",
|
||||
"Amplificateurs SPE : les boutons ON et OFF allument et éteignent désormais l’amplificateur par l’USB.",
|
||||
"Journal de diagnostic allégé : plus d’abonnements aux mètres FlexRadio, de ligne par paquet WSJT-X/MSHV, ni de trames statut Tuner Genius / PGXL / SPE répétées.",
|
||||
"Contrôle station : les cartes s’alignent désormais sur une grille — même largeur, et même hauteur sur une ligne — et le sélecteur de colonnes va jusqu’à 6.",
|
||||
"Nouveau widget amplificateur à côté des manipulateurs (icône flamme) : Operate, marche/arrêt, le niveau de puissance SPE et les watts / ROS / température en direct. Avec plusieurs amplis, une liste déroulante sur l’icône choisit celui à surveiller, ou tous.",
|
||||
"Icom : les mesures et les réglages de façade ne sont interrogés que lorsque la console Icom est affichée. Ils représentaient la moitié du trafic CI-V en permanence, ce qui compte quand la liaison est partagée — un Router microHAM qui répartit un port CI-V entre OpsLog et WSJT-X, par exemple.",
|
||||
"Icom : le bouton de mise sous tension envoie un préambule de réveil dimensionné pour la vitesse CI-V. Sa longueur était fixe et ne convenait qu’à 19200 bauds, si bien qu’une radio réglée plus vite — 115200 par défaut — ignorait la commande.",
|
||||
"Le mètre de puissance du Tuner Genius affiche désormais la crête mesurée par l’appareil lui-même, au lieu de l’instant capté par l’interrogation — une émission d’un kilowatt pouvait afficher quelques centaines de watts. Tous les mètres de puissance retombent aussi dès la fin de l’émission, au lieu de traîner plusieurs secondes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.8",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Main tab: drag the divider between the two panes to resize them. The setting is remembered and travels with the data folder; double-click the divider to even them out.",
|
||||
"Kenwood: bytes arriving glued to the previous answer are no longer discarded, and the link starts from a clean buffer — a rig whose replies ran together could end up one answer behind and never report connected.",
|
||||
"The CAT log line now carries the error explaining a disconnection.",
|
||||
"Kenwood and Yaesu CAT connect again: 0.22.7 lowered the DTR and RTS lines on those ports, which stops many USB-serial interfaces from transmitting. Only Xiegu, where the problem was reported, still does it.",
|
||||
"Kenwood: a port that opens but sends nothing is now reported differently from one sending data that does not answer, and the error quotes what arrived."
|
||||
],
|
||||
"fr": [
|
||||
"Onglet Principal : faites glisser le séparateur entre les deux volets pour les redimensionner. Le réglage est mémorisé et suit le dossier de données ; double-clic pour les égaliser.",
|
||||
"Kenwood : les octets reçus collés à la réponse précédente ne sont plus jetés, et la liaison démarre sur un tampon vide — une radio dont les réponses s’enchaînent pouvait rester une réponse en retard et ne jamais apparaître connectée.",
|
||||
"La ligne de journal CAT indique désormais l’erreur expliquant une déconnexion.",
|
||||
"Le CAT Kenwood et Yaesu se connecte de nouveau : la 0.22.7 abaissait les lignes DTR et RTS sur ces ports, ce qui empêche beaucoup d’interfaces USB-série d’émettre. Seul Xiegu, où le problème avait été signalé, le fait encore.",
|
||||
"Kenwood : un port qui s’ouvre sans rien émettre est désormais distingué d’un port qui émet sans répondre, et l’erreur cite ce qui est arrivé."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.7",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Serial CAT no longer keys the radio just by connecting: DTR and RTS are dropped on Xiegu, Yaesu and Kenwood as they already were on Icom — a Xiegu G90 behind a DE-19 went straight into transmit and stayed there.",
|
||||
"Xiegu: choose how the rig is keyed — CI-V command, RTS or DTR. A G90 does not transmit on the CI-V command, so this is also what lets WSJT-X transmit through the shared CAT link.",
|
||||
"A reference you assign by hand now replaces what the matcher found instead of being added to it, so a correction survives the next recompute.",
|
||||
"Awards: an award can be marked one reference per QSO. When several references then match the same contact it keeps none and lists them, so you pick — a contact does not silently get the wrong DOK. Off by default, so an n-fer POTA activation still counts every park.",
|
||||
"Dark themes: the calendar icon of date and date-time fields is visible again.",
|
||||
"Live award detection now sees the QTH, name, country, comment, note and grid you typed, not just the looked-up address — an award matching on the town stayed silent until the QSO was logged.",
|
||||
"Info (F2) panel: the fields are sized for what they hold — a wider county, narrower prefix and zones, and the address takes the space left by the bearing and distances.",
|
||||
"Clicking in the lower half no longer draws an orange rule under the tab strip.",
|
||||
"A green NEW badge appears on the County field in Info (F2) when the contacted US county has never been worked.",
|
||||
"DARC DOK award updated to v2 in the catalogue: one reference per QSO."
|
||||
],
|
||||
"fr": [
|
||||
"Le CAT série ne met plus la radio en émission par le seul fait de se connecter : DTR et RTS sont abaissés sur Xiegu, Yaesu et Kenwood comme ils l'étaient déjà sur Icom — un Xiegu G90 derrière un DE-19 partait en émission et y restait.",
|
||||
"Xiegu : choix du passage en émission — commande CI-V, RTS ou DTR. Un G90 n'émet pas sur la commande CI-V, c'est donc aussi ce qui permet à WSJT-X d'émettre via le partage CAT.",
|
||||
"Une référence que vous affectez à la main remplace désormais ce que la détection avait trouvé au lieu de s'y ajouter : une correction survit au recalcul suivant.",
|
||||
"Diplômes : un diplôme peut être marqué une seule référence par QSO. Si plusieurs références correspondent alors au même contact, aucune n’est retenue et elles sont listées pour que vous choisissiez — un contact ne reçoit plus silencieusement le mauvais DOK. Désactivé par défaut, donc une activation POTA n-fer compte toujours tous les parcs.",
|
||||
"Thèmes sombres : l’icône calendrier des champs date et date-heure est de nouveau visible.",
|
||||
"La détection des diplômes en direct voit maintenant le QTH, le nom, le pays, le commentaire, la note et le locator saisis, et plus seulement l’adresse issue de la recherche — un diplôme qui matche sur la ville restait muet jusqu’à l’enregistrement du QSO.",
|
||||
"Panneau Info (F2) : les champs sont dimensionnés selon leur contenu — comté plus large, préfixe et zones plus étroits, et l’adresse récupère la place laissée par l’azimut et les distances.",
|
||||
"Un clic dans la moitié basse ne trace plus de trait orange sous la barre d’onglets.",
|
||||
"Un badge vert NOUV apparaît sur le champ Comté dans Info (F2) quand le comté américain contacté n’a jamais été travaillé.",
|
||||
"Diplôme DARC DOK mis à jour en v2 dans le catalogue : une seule référence par QSO."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.6",
|
||||
"date": "",
|
||||
"en": [
|
||||
"A playback that fails to start now says so in the log instead of ending silently after a tenth of a second.",
|
||||
"Replaying a recording before the previous one has finished now works: the new playback waits for the audio device to be free instead of keying the radio and releasing at once.",
|
||||
"The play button becomes a stop button while the recording is going out: it cuts the transmission and releases the PTT, and you can send it again straight away.",
|
||||
"The \"From radio\" and mic levels take effect as you drag the slider, and the RX level now applies to what you HEAR through OpsLog, not only to recordings.",
|
||||
"The padlocks on frequency, band, mode and times no longer flicker under the pointer and refuse the click.",
|
||||
"The callsign field is wider, taking the room from the RST pair.",
|
||||
"MP3 recordings no longer carry a hiss of their own: the 16→32 kHz conversion mirrored the whole spectrum only 7 dB down. It is now 57 dB down.",
|
||||
"Replaying a QSO recording on the air has its own level, separate from the voice keyer: a recording comes from a receiver line output and needs far less gain than a message spoken into a microphone.",
|
||||
"A quiet DX cluster no longer looks like a broken one: silence stopped counting as a disconnection, so a node with few spots keeps its session, its login and its filters."
|
||||
],
|
||||
"fr": [
|
||||
"Une lecture qui ne démarre pas le signale désormais dans le journal, au lieu de s'arrêter en silence au bout d'un dixième de seconde.",
|
||||
"Relancer un enregistrement avant la fin du précédent fonctionne désormais : la nouvelle lecture attend que le périphérique audio soit libre, au lieu de passer en émission et de relâcher aussitôt.",
|
||||
"Le bouton de lecture devient un bouton d'arrêt pendant l'émission de l'enregistrement : il coupe l'émission et relâche le PTT, et vous pouvez le renvoyer aussitôt.",
|
||||
"Les niveaux « From radio » et micro agissent pendant que vous déplacez le curseur, et le niveau RX s'applique désormais à ce que vous ENTENDEZ dans OpsLog, pas seulement aux enregistrements.",
|
||||
"Les cadenas de fréquence, bande, mode et heures ne scintillent plus sous le curseur et acceptent le clic.",
|
||||
"Le champ indicatif est plus large, la place venant de la paire RST.",
|
||||
"Les enregistrements MP3 ne portent plus un souffle qui leur est propre : la conversion 16→32 kHz recopiait tout le spectre à seulement 7 dB en dessous. Il est désormais à 57 dB.",
|
||||
"La relecture d'un enregistrement de QSO a son propre niveau, distinct du manipulateur vocal : une prise vient de la sortie ligne d'un récepteur et demande bien moins de gain qu'un message dit au micro.",
|
||||
"Un cluster calme ne passe plus pour un cluster mort : le silence ne compte plus comme une déconnexion, donc un nœud avec peu de spots garde sa session, son login et ses filtres."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.5",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Cluster filters gain NEW POTA and NEW COUNTY, alongside the existing status chips."
|
||||
],
|
||||
"fr": [
|
||||
"Les filtres du cluster gagnent NEW POTA et NEW COUNTY, à côté des pastilles de statut existantes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.4",
|
||||
"date": "",
|
||||
"en": [
|
||||
"FlexRadio: in split, OpsLog stays on the receive slice instead of following the transmitter.",
|
||||
"QSO recording works on CW again (no sidetone).",
|
||||
"Playing a recording on the air now says why when it cannot.",
|
||||
"The play-on-air button is hidden on CW.",
|
||||
"CW decoder: a station replying at a different speed no longer decodes as a run of dashes, and callsigns are no longer split into single letters by a wide fist.",
|
||||
"FlexRadio: one table per band for the antennas and the TX power per mode class (phone / CW / digital). Antennas follow the band, power follows the band and the mode; an empty box leaves the power alone.",
|
||||
"Grouping the digital modes into one slot now takes effect on the spots already on screen, instead of only on those arriving afterwards.",
|
||||
"The QSO right-click menu no longer runs off the bottom of the window: it opens above the cursor when there is not enough room below.",
|
||||
"QRZ.com confirmations: only QRZ own confirmed marker counts now. The download also clears QSOs an earlier version marked confirmed when QRZ says they are not — run it once to correct your log.",
|
||||
"Awards: a mode filter (All / CW / Phone / Digital) that stacks with the worked/confirmed one — so \"worked on CW but not confirmed on CW\" is one click."
|
||||
],
|
||||
"fr": [
|
||||
"FlexRadio : en split, OpsLog reste sur la slice de réception au lieu de suivre l'émission.",
|
||||
"L'enregistrement des QSO fonctionne à nouveau en CW (sans le signal d'écoute).",
|
||||
"L'émission d'un enregistrement indique désormais pourquoi elle échoue.",
|
||||
"Le bouton d'émission de l'enregistrement est masqué en CW.",
|
||||
"Décodeur CW : une station qui répond à une autre vitesse ne se décode plus en série de traits, et les indicatifs ne sont plus coupés lettre par lettre par un espacement large.",
|
||||
"FlexRadio : un seul tableau par bande pour les antennes et la puissance d'émission par type de mode (phonie / CW / numérique). Les antennes suivent la bande, la puissance suit la bande et le mode ; une case vide ne touche pas à la puissance.",
|
||||
"Le regroupement des modes numériques en un seul créneau s'applique désormais aux spots déjà affichés, et non plus seulement à ceux qui arrivent ensuite.",
|
||||
"Le menu contextuel des QSO ne déborde plus en bas de la fenêtre : il s'ouvre au-dessus du curseur quand la place manque en dessous.",
|
||||
"Confirmations QRZ.com : seul le marqueur de confirmation propre à QRZ compte désormais. Le téléchargement efface aussi les QSO qu'une version antérieure avait marqués confirmés alors que QRZ dit le contraire — lancez-le une fois pour corriger votre journal.",
|
||||
"Diplômes : un filtre par mode (Tous / CW / Phonie / Numérique) qui se cumule avec le filtre contacté/confirmé — « contacté en CW mais pas confirmé en CW » se lit en un clic."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.3",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Kenwood backend tested end to end against the built-in TS-2000 emulator.",
|
||||
"Kenwood: split detected from FR/FT when the radio omits it from its status frame.",
|
||||
"CAT protocol trace now covers the Kenwood backend, not only CI-V.",
|
||||
"Updating selected QSOs from the callsign databases shows a progress bar, without blocking the rest of OpsLog. Menu entry renamed \"Update from the callsign databases\".",
|
||||
"Grey line on the world map: day/night terminator and twilight band, button at the top right.",
|
||||
"The CAT and WinKeyer trace checkboxes now show whether the trace is really running.",
|
||||
"A radio reporting LSB or USB now selects SSB in the entry form.",
|
||||
"Kenwood CAT over the network: give a host:port for a serial bridge instead of a COM port.",
|
||||
"CAT backends renamed by how the radio is reached: OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, network), Xiegu (USB), Icom (CI-V USB), Icom (CI-V network), TCI.",
|
||||
"With automatic recording off, a red dot beside the callsign records the contact by hand.",
|
||||
"A recording can be stopped and transmitted to the station being worked, like a voice-keyer message.",
|
||||
"Award references can be sorted by reference or by description.",
|
||||
"Deleting a QSO can also withdraw it from QRZ.com and Club Log — Settings → External services, off by default.",
|
||||
"QSL and upload status columns are coloured: Y green, N red, R blue.",
|
||||
"Settings → General chooses between US / FR / Standard for dates. Display only.",
|
||||
"New award: DARC DOK Award (DLD)."
|
||||
],
|
||||
"fr": [
|
||||
"Backend Kenwood testé de bout en bout contre l’émulateur TS-2000 intégré.",
|
||||
"Kenwood : split détecté via FR/FT quand la radio ne le renseigne pas dans sa trame d’état.",
|
||||
"La trace du protocole CAT couvre aussi le backend Kenwood, plus seulement le CI-V.",
|
||||
"La mise à jour des QSO sélectionnés depuis les annuaires affiche une barre de progression, sans bloquer le reste d’OpsLog. Entrée de menu renommée « Mettre à jour depuis les annuaires ».",
|
||||
"Ligne grise sur la carte du monde : terminateur jour/nuit et bande de crépuscule, bouton en haut à droite.",
|
||||
"Les cases de trace CAT et WinKeyer reflètent désormais l’état réel de la trace.",
|
||||
"Une radio annonçant LSB ou USB sélectionne désormais SSB dans la saisie.",
|
||||
"CAT Kenwood par le réseau : indiquez un hôte:port de pont série au lieu d’un port COM.",
|
||||
"Backends CAT renommés selon la façon dont la radio est jointe : OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, réseau), Xiegu (USB), Icom (CI-V USB), Icom (CI-V réseau), TCI.",
|
||||
"Enregistrement automatique désactivé : un rond rouge à côté de l’indicatif enregistre le contact à la main.",
|
||||
"Un enregistrement peut être arrêté puis émis vers la station travaillée, comme un message du manipulateur vocal.",
|
||||
"Les références d’un diplôme peuvent être triées par référence ou par description.",
|
||||
"Supprimer un QSO peut aussi le retirer de QRZ.com et Club Log — Réglages → Services externes, désactivé par défaut.",
|
||||
"Colonnes de statut QSL et d’envoi colorées : Y vert, N rouge, R bleu.",
|
||||
"Réglages → Général : choix US / FR / Standard pour les dates. Affichage seulement.",
|
||||
"Nouveau diplôme : DARC DOK Award (DLD)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.2",
|
||||
"date": "2026-07-30",
|
||||
"en": [
|
||||
"The Antenna Genius and Tuner Genius settings entries carry a small 4O3A mark, so the panels driving that hardware are recognisable at a glance in the sidebar.",
|
||||
"The database shown in the status bar is the LOGBOOK file. On a local SQLite setup it named the settings database instead, so anyone checking where their QSOs live — or which file to back up — was pointed at the wrong one.",
|
||||
"Yaesu: the mode follows the VFO you are on. A spot clicked while working the SUB receiver tuned the sub VFO but set the mode on MAIN, leaving the VFO in use on its old mode; the console read main mode too.",
|
||||
"Rotator control now covers any GS-232A controller, ERC (Easy Rotor Control) included: the serial speed is selectable and the entry is named after the protocol rather than one device. Set the ERC to GS-232 emulation, not Hy-Gain DCU-1.",
|
||||
"Native Kenwood CAT: a sixth backend talks straight to a TS-590, TS-890, TS-990 or TS-2000 over its serial port — frequency, mode, VFO, split and PTT — with no OmniRig in between. Elecraft K3/K4 speak the same dialect. Pick \"Kenwood (native)\" in Settings → CAT.",
|
||||
"Icom and Xiegu: a CI-V frame read at the wrong offset no longer turns into a frequency. Values such as 16445550000 Hz appeared in the status bar while the rig sat on 145.550 MHz — the decoder treated non-decimal bytes as digits. Such frames are now refused and the last good reading stands.",
|
||||
"Settings → CAT can log the CI-V protocol: every frame to and from an Icom or Xiegu, as hex. For reporting a radio that answers oddly — a button that does the wrong thing, a frequency that jumps — where a description alone leaves the cause to guesswork.",
|
||||
"A crash in the window no longer leaves a blank screen. The error is written to the app log and shown on screen, selectable, with a button to copy it and one to reload — so a fault can be reported with its cause instead of a description of an empty window.",
|
||||
"ADIF import can move fields as it reads them. Contest software stores the exchange where its own module keeps it — the RSGB IOTA contest exports the island reference in STATE, which imports as a US state and leaves the IOTA award empty. Add a STATE → IOTA row in the import dialog and it lands where the award looks.",
|
||||
"The recent-QSO grid no longer offers per-column filters. They only ever searched the rows on screen, so an empty result looked like a missing QSO when the log simply held it further back — use the advanced filter, which queries the whole logbook. Column headers gain the width back."
|
||||
],
|
||||
"fr": [
|
||||
"Les entrées de réglages Antenna Genius et Tuner Genius portent une petite marque 4O3A : les panneaux pilotant ce matériel se repèrent d'un coup d'œil dans le menu.",
|
||||
"La base affichée dans la barre d'état est bien le fichier du CARNET. En SQLite local, elle désignait la base des réglages : qui vérifiait où résident ses QSO — ou quel fichier sauvegarder — était orienté vers le mauvais.",
|
||||
"Yaesu : le mode suit le VFO utilisé. Un spot cliqué en travaillant sur le récepteur SUB accordait bien le VFO secondaire mais réglait le mode sur le MAIN, laissant le VFO en service sur son ancien mode ; la console lisait également le mode du principal.",
|
||||
"Le contrôle de rotator couvre désormais tout contrôleur GS-232A, ERC (Easy Rotor Control) compris : la vitesse du port série est sélectionnable et l'entrée porte le nom du protocole plutôt que celui d'un seul appareil. Réglez l'ERC sur l'émulation GS-232, pas Hy-Gain DCU-1.",
|
||||
"CAT Kenwood natif : un sixième backend parle directement à un TS-590, TS-890, TS-990 ou TS-2000 par son port série — fréquence, mode, VFO, split et PTT — sans OmniRig. Les Elecraft K3/K4 parlent le même dialecte. À choisir sous « Kenwood (natif) » dans Réglages → CAT.",
|
||||
"Icom et Xiegu : une trame CI-V lue au mauvais décalage ne se transforme plus en fréquence. Des valeurs comme 16445550000 Hz apparaissaient dans la barre d'état alors que la radio était sur 145,550 MHz — le décodeur prenait des octets non décimaux pour des chiffres. Ces trames sont désormais refusées et la dernière lecture valable est conservée.",
|
||||
"Réglages → CAT permet de journaliser le protocole CI-V : chaque trame échangée avec un Icom ou un Xiegu, en hexadécimal. Pour signaler une radio qui répond de travers — un bouton qui fait autre chose, une fréquence qui saute — là où une description seule laisse la cause à la devinette.",
|
||||
"Un plantage de la fenêtre ne laisse plus un écran vide. L'erreur est écrite dans le journal et affichée à l'écran, sélectionnable, avec un bouton pour la copier et un pour recharger — de quoi signaler un défaut avec sa cause plutôt qu'une description de fenêtre vide.",
|
||||
"L'import ADIF peut déplacer des champs à la lecture. Les logiciels de concours rangent l'échange là où leur module le garde — le contest IOTA de la RSGB exporte la référence d'île dans STATE, importée comme un état américain, et le diplôme IOTA reste vide. Une ligne STATE → IOTA dans la fenêtre d'import et la référence arrive là où le diplôme la cherche.",
|
||||
"La grille des QSO récents ne propose plus de filtres par colonne. Ils ne cherchaient que dans les lignes affichées : un résultat vide ressemblait à un QSO manquant alors que le journal le contenait plus loin — le filtre avancé, lui, interroge tout le journal. Les en-têtes de colonne récupèrent la place."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.1",
|
||||
"date": "2026-07-29",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// Which class of power a mode falls into.
|
||||
//
|
||||
// This decides how much power leaves the amplifier, so an error here is not a
|
||||
// display bug: a digital mode sorted as phone gets the SSB setting, which is
|
||||
// exactly the 1.5 kW into a 100% duty cycle this feature exists to prevent.
|
||||
func TestFlexPowerClass(t *testing.T) {
|
||||
for _, c := range []struct{ mode, want string }{
|
||||
{"SSB", "phone"}, {"USB", "phone"}, {"LSB", "phone"}, {"AM", "phone"}, {"FM", "phone"},
|
||||
{"CW", "cw"}, {"cw", "cw"}, {" CW ", "cw"},
|
||||
{"FT8", "digi"}, {"FT4", "digi"}, {"RTTY", "digi"}, {"PSK31", "digi"},
|
||||
{"JS8", "digi"}, {"Q65", "digi"}, {"MSK144", "digi"}, {"DATA", "digi"},
|
||||
{"DIGU", "digi"}, {"DIGL", "digi"}, {"WSPR", "digi"},
|
||||
// Unknown or empty: no class, and the caller changes nothing. Setting a
|
||||
// transmit power from a name we do not recognise is not a good guess.
|
||||
{"", ""}, {"SSTV", ""}, {"BANANA", ""},
|
||||
} {
|
||||
if got := flexPowerClass(c.mode); got != c.want {
|
||||
t.Errorf("flexPowerClass(%q) = %q, want %q", c.mode, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+796
-128
File diff suppressed because it is too large
Load Diff
@@ -51,11 +51,14 @@ type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgx
|
||||
|
||||
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
|
||||
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
|
||||
// One second: the same window as the docked widget, so the two never show
|
||||
// different figures for the same amplifier, and neither trails the end of a
|
||||
// transmission long enough to look like lag.
|
||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||
const peakHold = (key: string, val: number) => {
|
||||
const now = Date.now();
|
||||
const p = peak.current[key];
|
||||
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
|
||||
@@ -73,9 +76,12 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
|
||||
is off (no status = not "connected"), and serial only. */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!spe.connected}
|
||||
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
|
||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||
@@ -177,9 +183,6 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
||||
</span>
|
||||
{(pg.host || pg.connected) && (
|
||||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||||
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
@@ -208,6 +211,11 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||
if (amps.length === 0) return null;
|
||||
// Power comes from the radio's meter stream and nothing else. The
|
||||
// amplifier also reports a "peakfwd", and using it was a mistake twice
|
||||
// over: it is a latched maximum that is never reset, and it survives in
|
||||
// the last-known status after the amp disconnects — so it claimed
|
||||
// 1350 W from an old transmission while the radio was putting out 10.
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||
{amps.map((m) => {
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useRef } from 'react';
|
||||
import { Flame, X, Power } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
|
||||
|
||||
// AmpWidget — the docked amplifier widget, the compact counterpart of AmpCard
|
||||
// (which is the full-size card shown in the FlexRadio panel and Station
|
||||
// Control). It covers all three families, since an operator running an SPE and
|
||||
// a PowerGenius wants one widget, not two:
|
||||
// • SPE Expert — OPERATE, power ON/OFF, L/M/H, live power / SWR / temp
|
||||
// • ACOM — OPERATE, power ON/OFF, live power / SWR / temp
|
||||
// • PowerGenius — OPERATE and meters ride on the FlexRadio, fan mode on GSCP
|
||||
// With several amplifiers configured the caller passes a selection ("all" or an
|
||||
// amp id) chosen from the toolbar icon's dropdown.
|
||||
|
||||
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
||||
|
||||
// Full-scale watts for the output bar, from the model string.
|
||||
function maxW(model?: string, fallback = 1300): number {
|
||||
const m = (model || '').toUpperCase();
|
||||
if (m.includes('20K') || m.includes('2K')) return 2000;
|
||||
if (m.includes('15K')) return 1500;
|
||||
if (m.includes('13K')) return 1300;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function swrColour(swr?: number): string {
|
||||
if (!swr || swr <= 0) return 'text-muted-foreground';
|
||||
if (swr <= 1.5) return 'text-success';
|
||||
if (swr <= 2.0) return 'text-warning';
|
||||
return 'text-danger';
|
||||
}
|
||||
|
||||
// PowerMeter — the output-power LED bar that closes each column, straight under
|
||||
// the controls (no filler above it: pushed to the bottom it left a band of dead
|
||||
// space in the middle of the column). Uses the app-wide MeterBar, compact size,
|
||||
// so it reads exactly like the meters on the rig panels.
|
||||
function PowerMeter({ label, watts, maxWatts }: { label: string; watts: number; maxWatts: number }) {
|
||||
return (
|
||||
<MeterBar compact label={label} value={watts} unit="W" lo={0} hi={maxWatts}
|
||||
display={`${Math.round(watts)} W`}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
);
|
||||
}
|
||||
|
||||
// usePeakHold keeps the highest reading seen for a short window, so a reading
|
||||
// sampled between two syllables doesn't collapse the meter. `live` is the key
|
||||
// part: pass false the moment the carrier drops and the held value is thrown
|
||||
// away at once — holding it there made releasing the PTT look like a five
|
||||
// second lag, which read as the whole app being slow.
|
||||
function usePeakHold(windowMs = 1000) {
|
||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||
return (key: string, val: number, live = true) => {
|
||||
if (!live) { delete peak.current[key]; return val; }
|
||||
const now = Date.now();
|
||||
const p = peak.current[key];
|
||||
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
}
|
||||
|
||||
// Stat — one small labelled figure (power, SWR, temperature).
|
||||
function Stat({ label, value, className }: { label: string; value: string; className?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card/70 px-1.5 py-1 text-center">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground leading-tight">{label}</div>
|
||||
<div className={cn('text-sm font-bold font-mono leading-tight', className ?? 'text-foreground/80')}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// OperateButton — the STANDBY/OPERATE toggle, identical in behaviour across the
|
||||
// three families (only where the state comes from differs).
|
||||
function OperateButton({ operate, disabled, onClick, t }: {
|
||||
operate: boolean; disabled: boolean; onClick: () => void; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" disabled={disabled} onClick={onClick}
|
||||
className={cn('w-full inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-[0.98] disabled:opacity-40',
|
||||
operate
|
||||
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_9px_rgba(245,158,11,0.45)]'
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground')}>
|
||||
<Power className="size-3.5" />
|
||||
{operate ? t('ampw.operate') : t('ampw.standby')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// AmpBlock — one amplifier inside the widget.
|
||||
function AmpBlock({ amp, flex, showName, t }: {
|
||||
amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
const spe = amp.spe, acom = amp.acom;
|
||||
const hold = usePeakHold();
|
||||
|
||||
if (spe || acom) {
|
||||
const s = spe || acom;
|
||||
// The amp reports zero watts on receive, so its TX flag clears the meter as
|
||||
// soon as the operator lets go — and the radio's own flag, when we have one,
|
||||
// gets there first (the amp is polled on its own slower cycle).
|
||||
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false;
|
||||
const w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing);
|
||||
const swr = Number(spe ? s.swr_ant : s.swr) || 0;
|
||||
const hi = spe ? maxW(s.model) : (Number(s.max_w) || 800);
|
||||
// Power ON drives the remote-on control lines, so it stays available while
|
||||
// the amplifier is off and reporting nothing — but only over a serial link.
|
||||
const canPowerOn = spe ? (s.connected || s.transport === 'serial') : (s.port_open && s.transport === 'serial');
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-1.5">
|
||||
{showName && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', s.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : 'ACOM')}</span>
|
||||
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
|
||||
</div>
|
||||
)}
|
||||
<OperateButton operate={!!s.operate} disabled={!s.connected} t={t}
|
||||
onClick={() => AmpOperate(amp.id, !s.operate).catch(() => {})} />
|
||||
<div className="flex gap-1.5">
|
||||
<button type="button" disabled={!canPowerOn}
|
||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||
className="flex-1 rounded-lg border border-success/60 bg-card py-1 text-[11px] font-bold text-success hover:bg-success/15 disabled:opacity-30">
|
||||
{t('ampw.on')}
|
||||
</button>
|
||||
<button type="button" disabled={!s.connected}
|
||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||
className="flex-1 rounded-lg border border-danger/60 bg-card py-1 text-[11px] font-bold text-danger hover:bg-danger/15 disabled:opacity-30">
|
||||
{t('ampw.off')}
|
||||
</button>
|
||||
</div>
|
||||
{/* Output level — SPE only; an ACOM has no L/M/H over CAT. */}
|
||||
{spe && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{(['L', 'M', 'H'] as const).map((lvl) => {
|
||||
const active = (s.power_level || '').trim().toUpperCase() === lvl;
|
||||
return (
|
||||
<button key={lvl} type="button" disabled={!s.connected}
|
||||
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
|
||||
className={cn('rounded-md border py-0.5 text-[10px] font-bold transition-colors disabled:opacity-30',
|
||||
active ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{t(`ampw.lvl${lvl}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{s.connected ? (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<Stat label={t('ampw.pwr')} value={`${Math.round(w)}`} />
|
||||
<Stat label={t('ampw.swr')} value={swr > 0 ? swr.toFixed(1) : '—'} className={swrColour(swr)} />
|
||||
<Stat label={t('ampw.temp')} value={`${Number(s.temp_c) || 0}°`} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||
)}
|
||||
{(s.warnings || s.alarms || s.err_text) && (
|
||||
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
||||
{s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
|
||||
</div>
|
||||
)}
|
||||
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PowerGenius XL: OPERATE and the meters come from the radio when the Flex
|
||||
// reports the amp; the fan mode rides on our own GSCP link.
|
||||
const pg = amp.pgxl || {};
|
||||
const viaFlex = !!flex?.amp_available;
|
||||
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
||||
const connected = !!pg.connected || viaFlex;
|
||||
const fault = flex?.amp_fault;
|
||||
const meters = ((flex?.meters as any[]) || []).filter((m) => (m.src || '').toUpperCase().includes('AMP'));
|
||||
const meter = (re: RegExp) => meters.find((m) => re.test((m.name || '').trim()));
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
const fwd = meter(/^fwd|pwr/i);
|
||||
const flexW = fwd ? (/dbm/i.test(fwd.unit || '') ? dbmToW(fwd.value) : fwd.value) : 0;
|
||||
// Whether there is power is the RADIO's transmit flag: it flips the instant
|
||||
// the PTT does, where the amplifier's own status poll answers a second and a
|
||||
// half late and holds TRANSMIT past the carrier.
|
||||
//
|
||||
// How MUCH is the live reading — the radio's meter stream, or the amp's own
|
||||
// forward figure when there is no Flex. NOT the amp's "peakfwd": that is a
|
||||
// latched maximum which is never reset AND survives in the last-known status
|
||||
// after the amp disconnects, so it reported 1350 W from an old transmission
|
||||
// while the radio was putting out 10 W into a disconnected amplifier.
|
||||
const txing = typeof flex?.transmitting === 'boolean'
|
||||
? flex.transmitting
|
||||
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
|
||||
// Prefer the radio's VITA meter stream when it is actually flowing (local or
|
||||
// over a VPN) — it is fast and matches SmartSDR. Fall back to the amp's OWN
|
||||
// link only when the radio isn't streaming its meters (a remote link without
|
||||
// the VITA path), so the reading survives but is naturally slower.
|
||||
const liveW = flexW > 0 ? flexW : (pg.connected ? Number(pg.fwd_w) || 0 : 0);
|
||||
const fwdW = hold('fwd', txing ? liveW : 0, txing);
|
||||
const id = meter(/^ID$|current/i);
|
||||
// Live drain current, same source preference. peak_id latches like peak_w, so
|
||||
// use the instantaneous 'id' and gate it on the radio's transmit flag.
|
||||
const idA = id ? Number(id.value) : (txing ? Number(pg.id) || undefined : 0);
|
||||
const temp = meter(/temp/i);
|
||||
// VITA temp matches SmartSDR (the amp's direct link exposes a different sensor
|
||||
// that can read ~10°C higher); fall back to the direct temp when no VITA.
|
||||
const tempC = temp ? temp.value : (pg.temperature > 0 ? pg.temperature : undefined);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-1.5">
|
||||
{showName && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || 'PGXL'}</span>
|
||||
</div>
|
||||
)}
|
||||
<OperateButton operate={operate} disabled={!connected} t={t}
|
||||
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})} />
|
||||
{connected ? (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<Stat label={t('ampw.pwr')} value={`${Math.round(fwdW)}`} />
|
||||
<Stat label={t('ampw.id')} value={idA != null ? idA.toFixed(1) : '—'} />
|
||||
<Stat label={t('ampw.temp')} value={tempC != null ? `${Math.round(tempC)}°` : '—'} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||
)}
|
||||
{(pg.host || pg.connected) && (
|
||||
<select
|
||||
disabled={!pg.connected}
|
||||
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
|
||||
title={t('ampw.fan')}
|
||||
className="w-full h-7 rounded-md border border-border bg-card px-1.5 text-[10px] font-semibold outline-none focus:border-warning disabled:opacity-40">
|
||||
<option value="STANDARD">{t('flxp.fanStandard')}</option>
|
||||
<option value="CONTEST">{t('flxp.fanContest')}</option>
|
||||
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
|
||||
</select>
|
||||
)}
|
||||
{fault && fault !== 'NONE' && (
|
||||
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
||||
{t('flxp.fault')}: {fault}
|
||||
</div>
|
||||
)}
|
||||
<PowerMeter label={t('flxp.outputPower')} watts={connected ? fwdW : 0} maxWatts={2000} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmpWidget({ amps, flex, sel, onClose }: {
|
||||
amps: Amp[];
|
||||
flex: any;
|
||||
sel: string; // "all" or an amp id
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
// An id that no longer matches (the amp was removed in Settings) falls back to
|
||||
// showing everything rather than an empty widget.
|
||||
const shown = sel === 'all' ? amps : (amps.filter((a) => a.id === sel).length ? amps.filter((a) => a.id === sel) : amps);
|
||||
const anyOnline = shown.some((a) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flex?.amp_available);
|
||||
const title = shown.length === 1 ? (shown[0].name || t('ampw.title')) : t('ampw.title');
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||
<Flame className={cn('size-4', anyOnline ? 'text-warning drop-shadow-[0_0_3px_rgba(245,158,11,0.55)]' : 'text-muted-foreground')} />
|
||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80 truncate">{title}</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('ampw.close')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Several amplifiers sit SIDE BY SIDE, one column each: stacked, two amps
|
||||
made a widget taller than the dock row and both ended up half hidden. */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2.5">
|
||||
{shown.length === 0 ? (
|
||||
<div className="text-[11px] text-center text-muted-foreground italic py-2">{t('ampw.none')}</div>
|
||||
) : (
|
||||
<div className="grid gap-2.5 h-full" style={{ gridTemplateColumns: `repeat(${shown.length}, minmax(0, 1fr))` }}>
|
||||
{shown.map((a, i) => (
|
||||
<div key={a.id} className={cn(i > 0 && 'pl-2.5 border-l border-border/50')}>
|
||||
<AmpBlock amp={a} flex={flex} showName={shown.length > 1} t={t} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export type AwardDef = {
|
||||
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
||||
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
||||
ref_display?: string; // grid column shows: ref | name | both
|
||||
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
||||
type?: string; field: string; match_by?: string; exact_match?: boolean; one_ref_per_qso?: boolean; pattern: string;
|
||||
leading_str?: string; trailing_str?: string; dynamic?: boolean;
|
||||
or_rules?: AwardOrRule[];
|
||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||
@@ -227,7 +227,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
};
|
||||
type Explanation = {
|
||||
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
|
||||
ref_count: number; steps: Step[]; manual?: string[]; result: string[];
|
||||
ref_count: number; steps: Step[]; manual?: string[]; ambiguous?: string[]; result: string[];
|
||||
};
|
||||
type TestRow = { qso: any; explanation: Explanation };
|
||||
const [testCall, setTestCall] = useState('');
|
||||
@@ -554,6 +554,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</div>
|
||||
</Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> {t('awed.exactMatch')}</label>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]" title={t('awed.oneRefHint')}><Checkbox checked={!!cur.one_ref_per_qso} onCheckedChange={(c) => patch({ one_ref_per_qso: !!c })} /> {t('awed.oneRef')}</label>
|
||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||
@@ -708,6 +709,13 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(ex.ambiguous ?? []).length > 0 && (
|
||||
<div className="px-3 py-2 text-xs">
|
||||
<span className="font-semibold uppercase text-[10px] tracking-wide text-warning">{t('awed.testAmbiguous')}</span>{' '}
|
||||
<span className="font-mono text-warning">{(ex.ambiguous ?? []).join(', ')}</span>{' '}
|
||||
<span className="text-muted-foreground">{t('awed.testAmbiguousHint')}</span>
|
||||
</div>
|
||||
)}
|
||||
{(ex.manual ?? []).length > 0 && (
|
||||
<div className="px-3 py-2 text-xs">
|
||||
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards } from '../../wailsjs/go/main/App';
|
||||
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards, GetTrackedAwards } from '../../wailsjs/go/main/App';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -8,12 +9,15 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AwardEditor } from '@/components/AwardEditor';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
type BandCount = { band: string; worked: number; confirmed: number };
|
||||
type AwardRef = {
|
||||
ref: string; name?: string; group?: string; subgrp?: string;
|
||||
worked: boolean; confirmed: boolean; validated: boolean;
|
||||
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
|
||||
// Mode CLASSES — "CW" | "PHONE" | "DIGI" — not ADIF modes.
|
||||
modes?: string[]; confirmed_modes?: string[];
|
||||
};
|
||||
type AwardResult = {
|
||||
code: string; name: string; dimension: string;
|
||||
@@ -66,6 +70,9 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
||||
// Computed results are cached per award code — each award is scanned only the
|
||||
// first time it's selected (or when explicitly rescanned).
|
||||
// Keyed by "CODE|MODECLASS": the same award computed for CW and for all modes
|
||||
// are different results, and caching them under one key showed the previous
|
||||
// mode’s numbers after switching.
|
||||
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
@@ -74,6 +81,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
||||
// Mode filter, stacked ON TOP of the status one. "Worked on CW but not
|
||||
// confirmed" is two questions at once, and answering only one of them is what
|
||||
// sends an operator to a spreadsheet.
|
||||
const [modeFilter, setModeFilter] = useState<'all' | 'CW' | 'PHONE' | 'DIGI'>('all');
|
||||
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
|
||||
const [showMissing, setShowMissing] = useState(false);
|
||||
const [stats, setStats] = useState<AwardStats | null>(null);
|
||||
@@ -112,13 +123,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
// Compute one award (cached). force=true bypasses the cache (Rescan).
|
||||
async function compute(code: string, force = false) {
|
||||
if (!code) return;
|
||||
if (!force && byCode[code]) { setSelected(code); return; }
|
||||
const key = `${code}|${modeFilter}`;
|
||||
if (!force && byCode[key]) { setSelected(code); return; }
|
||||
setSelected(code);
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const r = (await GetAward(code)) as any as AwardResult;
|
||||
setByCode((m) => ({ ...m, [code]: r }));
|
||||
const r = (await GetAward(code, modeFilter === 'all' ? '' : modeFilter)) as any as AwardResult;
|
||||
setByCode((m) => ({ ...m, [key]: r }));
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
@@ -126,13 +138,20 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
}
|
||||
}
|
||||
|
||||
// Load the award list (no QSO scan), then compute only the first award.
|
||||
// Load the award list (no QSO scan), then compute only the first award. The
|
||||
// list is narrowed to the awards the operator follows (Settings → Awards); an
|
||||
// empty follow-set means "show them all" so the tab is never blank.
|
||||
async function loadList() {
|
||||
try {
|
||||
const defs = ((await GetAwardDefs()) ?? []) as any[];
|
||||
const list: AwardListItem[] = defs
|
||||
const [defs, tracked] = await Promise.all([
|
||||
GetAwardDefs().then((d) => (d ?? []) as any[]),
|
||||
GetTrackedAwards().then((t) => (t ?? []) as string[]).catch(() => [] as string[]),
|
||||
]);
|
||||
const follow = new Set(tracked);
|
||||
let list: AwardListItem[] = defs
|
||||
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [] }))
|
||||
.sort((a, b) => a.code.localeCompare(b.code));
|
||||
if (follow.size > 0) list = list.filter((a) => follow.has(a.code));
|
||||
setAwardList(list);
|
||||
const first = list.find((a) => a.code === selected) ?? list[0];
|
||||
if (first) compute(first.code);
|
||||
@@ -141,8 +160,17 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
}
|
||||
}
|
||||
useEffect(() => { loadList(); }, []);
|
||||
// Re-filter when the operator changes their followed awards in Settings.
|
||||
useEffect(() => {
|
||||
const off = EventsOn('awards:tracked-changed', () => { loadList(); });
|
||||
return () => { off(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const current = byCode[selected];
|
||||
const current = byCode[`${selected}|${modeFilter}`];
|
||||
// Recompute when the mode class changes: the bands, counts and confirmations
|
||||
// all describe the selected mode, so they cannot be filtered client-side.
|
||||
useEffect(() => { if (selected) compute(selected); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [modeFilter]);
|
||||
|
||||
// Bands relevant to the selected award, used by BOTH the grid and the stats
|
||||
// matrix so neither is padded with bands the award doesn't use. Rule: the
|
||||
@@ -191,17 +219,81 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
return idx.length ? idx : all;
|
||||
}, [stats, awardBands]);
|
||||
|
||||
// Sort order for the reference table. Reference is the default because that
|
||||
// is how award lists are published and how an operator reads a paper list;
|
||||
// description answers the other question — "have I worked anything in
|
||||
// Alicante" — which reference order scatters across the whole table.
|
||||
// Remembered through writeUiPref rather than localStorage, so the choice
|
||||
// travels with a copied data/ folder like every other UI preference here.
|
||||
const [refSort, setRefSort] = useState<'ref' | 'name' | 'group'>(
|
||||
() => {
|
||||
const v = localStorage.getItem('opslog.awardRefSort');
|
||||
return v === 'name' || v === 'group' ? v : 'ref';
|
||||
});
|
||||
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
|
||||
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
|
||||
const toggleRefSort = (k: 'ref' | 'name' | 'group') => {
|
||||
if (k === refSort) {
|
||||
const d = refSortDir === 'asc' ? 'desc' : 'asc';
|
||||
setRefSortDir(d);
|
||||
writeUiPref('opslog.awardRefSortDir', d);
|
||||
} else {
|
||||
setRefSort(k);
|
||||
setRefSortDir('asc');
|
||||
writeUiPref('opslog.awardRefSort', k);
|
||||
writeUiPref('opslog.awardRefSortDir', 'asc');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredRefs = useMemo(() => {
|
||||
if (!current) return [];
|
||||
const q = refSearch.trim().toUpperCase();
|
||||
return (current.refs ?? []).filter((r) => {
|
||||
const rows = (current.refs ?? []).filter((r) => {
|
||||
if (refFilter === 'worked' && !r.worked) return false;
|
||||
if (refFilter === 'notworked' && r.worked) return false;
|
||||
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||
if (modeFilter !== 'all' && refFilter !== 'notworked') {
|
||||
// A reference never worked has no mode, so "not worked" plus a mode is
|
||||
// a contradiction: the mode filter stands aside rather than emptying
|
||||
// the list.
|
||||
if (!(r.modes ?? []).includes(modeFilter)) return false;
|
||||
// Not-confirmed must mean not confirmed ON THIS MODE. An entity worked
|
||||
// on CW and confirmed on SSB is still a CW entity to chase, and the
|
||||
// whole point of the filter is to find those.
|
||||
if (refFilter === 'worked_notconf' && (r.confirmed_modes ?? []).includes(modeFilter)) return false;
|
||||
}
|
||||
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
|
||||
return true;
|
||||
});
|
||||
}, [current, refSearch, refFilter]);
|
||||
const dir = refSortDir === 'asc' ? 1 : -1;
|
||||
return rows.sort((a, b) => {
|
||||
if (refSort === 'name') {
|
||||
// localeCompare, not a byte comparison: these lists are Spanish, French
|
||||
// and Italian place names, where "Ávila" belongs beside "Avilés" rather
|
||||
// than after "Zaragoza".
|
||||
const c = (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' });
|
||||
if (c !== 0) return c * dir;
|
||||
return a.ref.localeCompare(b.ref) * dir; // stable tie-break
|
||||
}
|
||||
if (refSort === 'group') {
|
||||
// Prefix order (XE, YV, ZL…) — how a DX operator actually thinks about
|
||||
// entities. Blank prefixes sink to the bottom regardless of direction,
|
||||
// so an unresolved entity never heads the list.
|
||||
const ag = a.group ?? '', bg = b.group ?? '';
|
||||
if (!ag !== !bg) return ag ? -1 : 1;
|
||||
const c = ag.localeCompare(bg, undefined, { sensitivity: 'base' });
|
||||
if (c !== 0) return c * dir;
|
||||
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||
}
|
||||
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||
});
|
||||
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
|
||||
|
||||
// The group column earns its width only when the list actually carries one
|
||||
// (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the
|
||||
// value IS the primary prefix, so it is labelled as such.
|
||||
const hasGroup = useMemo(() => filteredRefs.some((r) => !!r.group), [filteredRefs]);
|
||||
const groupColLabel = current && current.code.toUpperCase() === 'DXCC' ? t('awp.prefixCol') : t('awp.groupCol');
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
@@ -240,7 +332,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
<div className="flex-1 overflow-auto">
|
||||
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
|
||||
{awardList.map((a) => {
|
||||
const r = byCode[a.code];
|
||||
const r = byCode[`${a.code}|${modeFilter}`];
|
||||
return (
|
||||
<button
|
||||
key={a.code}
|
||||
@@ -325,6 +417,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||
{([['all', t('awp.filterAll')], ['CW', 'CW'], ['PHONE', t('awp.modePhone')], ['DIGI', t('awp.modeDigital')]] as const).map(([k, label]) => (
|
||||
<button key={k} onClick={() => setModeFilter(k)}
|
||||
className={cn('px-2 py-1', modeFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||
<button
|
||||
onClick={() => setShowMissing(true)}
|
||||
@@ -386,8 +486,26 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-card">
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">{t('awp.ref')}</th>
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</th>
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">
|
||||
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.ref')}
|
||||
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
{hasGroup && (
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border w-20">
|
||||
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{groupColLabel}
|
||||
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
)}
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">
|
||||
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.description')}
|
||||
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
{gridBands.map((b) => (
|
||||
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
|
||||
))}
|
||||
@@ -397,9 +515,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
{filteredRefs.map((r) => (
|
||||
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
|
||||
<td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
|
||||
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">
|
||||
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
|
||||
</td>
|
||||
{hasGroup && (
|
||||
<td className="py-1 pr-3 font-mono font-semibold border-b border-border/30">{r.group}</td>
|
||||
)}
|
||||
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">{r.name}</td>
|
||||
{gridBands.map((b) => {
|
||||
const s = cellStatus(r, b);
|
||||
return (
|
||||
@@ -424,9 +543,24 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.ref')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium">{t('awp.name')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-24">
|
||||
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.ref')}
|
||||
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-1.5 pr-2 font-medium">
|
||||
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.name')}
|
||||
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-40">
|
||||
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{groupColLabel}
|
||||
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
|
||||
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
||||
</tr>
|
||||
@@ -459,7 +593,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
</div>
|
||||
|
||||
{cell && current && (
|
||||
<CellQSOModal code={current.code} cell={cell} onClose={() => setCell(null)} />
|
||||
<CellQSOModal code={current.code} cell={cell} modeClass={modeFilter === 'all' ? '' : modeFilter} onClose={() => setCell(null)} />
|
||||
)}
|
||||
{showMissing && current && (
|
||||
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
|
||||
@@ -644,17 +778,17 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
}
|
||||
|
||||
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
|
||||
function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; onClose: () => void }) {
|
||||
function CellQSOModal({ code, cell, modeClass, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; modeClass: string; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [qsos, setQsos] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
AwardCellQSOs(code, cell.ref, cell.band)
|
||||
AwardCellQSOs(code, cell.ref, cell.band, modeClass)
|
||||
.then((r) => setQsos((r ?? []) as any))
|
||||
.catch(() => setQsos([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [code, cell.ref, cell.band]);
|
||||
}, [code, cell.ref, cell.band, modeClass]);
|
||||
|
||||
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
||||
|
||||
|
||||
@@ -358,6 +358,43 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, [range]);
|
||||
|
||||
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
||||
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
||||
// Only active on the docked Main-view map (keyNav) and ignored while typing.
|
||||
// MUST stay ABOVE the `if (!range)` early return below: on an unknown band
|
||||
// (e.g. a spurious 33 cm CAT reading from an Icom) that return skipped this
|
||||
// hook, so the hook count changed between renders and React crashed with #300
|
||||
// ("rendered fewer hooks than expected"). lo/hi are always defined (they fall
|
||||
// back to [0,1] when there's no range), so it's safe to run here.
|
||||
useEffect(() => {
|
||||
if (!keyNav) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||
const ae = document.activeElement as HTMLElement | null;
|
||||
const tag = (ae?.tagName || '').toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select' || ae?.isContentEditable) return;
|
||||
const list = spots
|
||||
.filter((s) => (s.band ?? '') === band && s.freq_hz > 0)
|
||||
.slice()
|
||||
.sort((a, b) => a.freq_hz - b.freq_hz);
|
||||
if (!list.length) return;
|
||||
const cur = currentFreqHz || (lo + hi) * 500; // mid-band kHz→Hz when no rig freq
|
||||
const EPS = 50; // Hz, so we don't re-pick the spot we're already sitting on
|
||||
let target: Spot | undefined;
|
||||
if (e.key === 'ArrowUp') {
|
||||
target = list.find((s) => s.freq_hz > cur + EPS);
|
||||
} else {
|
||||
for (let i = list.length - 1; i >= 0; i--) { if (list[i].freq_hz < cur - EPS) { target = list[i]; break; } }
|
||||
}
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
onSpotClick(target);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
|
||||
|
||||
if (!range) {
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center text-xs text-muted-foreground p-3 bg-muted/20">
|
||||
@@ -388,38 +425,6 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
|
||||
}
|
||||
|
||||
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
||||
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
||||
// Only active on the docked Main-view map (keyNav) and ignored while typing.
|
||||
useEffect(() => {
|
||||
if (!keyNav) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||
const ae = document.activeElement as HTMLElement | null;
|
||||
const tag = (ae?.tagName || '').toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select' || ae?.isContentEditable) return;
|
||||
const list = spots
|
||||
.filter((s) => (s.band ?? '') === band && s.freq_hz > 0)
|
||||
.slice()
|
||||
.sort((a, b) => a.freq_hz - b.freq_hz);
|
||||
if (!list.length) return;
|
||||
const cur = currentFreqHz || (lo + hi) * 500; // mid-band kHz→Hz when no rig freq
|
||||
const EPS = 50; // Hz, so we don't re-pick the spot we're already sitting on
|
||||
let target: Spot | undefined;
|
||||
if (e.key === 'ArrowUp') {
|
||||
target = list.find((s) => s.freq_hz > cur + EPS);
|
||||
} else {
|
||||
for (let i = list.length - 1; i >= 0; i--) { if (list[i].freq_hz < cur - EPS) { target = list[i]; break; } }
|
||||
}
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
onSpotClick(target);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
|
||||
|
||||
const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
|
||||
const showRigPointer = currentKHz >= lo && currentKHz <= hi;
|
||||
const rigY = freqToY(currentKHz);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Star, Radio } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Star, Radio, Sunrise, Sunset, X, Loader2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sunTimes } from '@/lib/sun';
|
||||
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
|
||||
import type { WorkedBeforeView } from '@/types';
|
||||
|
||||
type WorkedBefore = WorkedBeforeView;
|
||||
@@ -13,6 +15,16 @@ interface Props {
|
||||
currentMode: string;
|
||||
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
||||
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
||||
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
|
||||
// to an entity with no position at all, and the block simply does not appear.
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
// Set when the matrix is showing a QSO picked in the log grid rather than the
|
||||
// entry form — labelled so the two can never be confused.
|
||||
forCall?: string;
|
||||
// Open the QSO editor for a contact — makes callsigns in the cell drill-down
|
||||
// clickable. Threaded down from App.openEdit.
|
||||
onEditQso?: (id: number) => void;
|
||||
}
|
||||
|
||||
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
|
||||
@@ -84,7 +96,9 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
|
||||
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
||||
}
|
||||
|
||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: Props) {
|
||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
|
||||
// Cell drill-down: which band+class the operator clicked, or null.
|
||||
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
|
||||
// Columns from the operator's configured bands (so the matrix shows only the
|
||||
// bands they actually use), falling back to the built-in default set.
|
||||
const cols = useMemo(
|
||||
@@ -151,6 +165,36 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
</Badge>
|
||||
) : null;
|
||||
|
||||
// Sunrise / sunset AT THE DX, in UTC. A glance tells you whether the path is
|
||||
// about to open or close on their side, which is the reason to look at a DX
|
||||
// station's grey line at all. Recomputed only when the position changes.
|
||||
const sun = useMemo(
|
||||
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
|
||||
[lat, lon],
|
||||
);
|
||||
const sunBlock = sun ? (
|
||||
<div className="ml-auto flex items-center gap-3 text-xs shrink-0"
|
||||
title="Sunrise / sunset at the DX station (UTC)">
|
||||
{sun.polarDay ? (
|
||||
<span className="font-semibold text-warning">midnight sun</span>
|
||||
) : sun.polarNight ? (
|
||||
<span className="font-semibold text-info">polar night</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<Sunrise className="size-3.5 text-warning" />
|
||||
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Sunset className="size-3.5 text-info" />
|
||||
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">UTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
@@ -158,7 +202,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-[220px]">
|
||||
<div className="flex items-center gap-2 min-w-[220px] flex-1">
|
||||
{newOne ? (
|
||||
<>
|
||||
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
||||
@@ -173,6 +217,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
</>
|
||||
) : hasDxcc ? (
|
||||
<>
|
||||
{/* Says WHOSE stats these are when they come from a row picked in
|
||||
the log rather than from what's being typed. */}
|
||||
{forCall && (
|
||||
<Badge variant="outline" className="px-2 py-0.5 text-[10px] font-mono shrink-0"
|
||||
title="Stats for the QSO selected in the log">
|
||||
{forCall}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
||||
{dxccName || `DXCC #${dxcc}`}
|
||||
</Badge>
|
||||
@@ -207,6 +259,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
Type a callsign to see entity stats
|
||||
</span>
|
||||
)}
|
||||
{sunBlock}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -246,10 +299,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
return (
|
||||
<td
|
||||
key={b.tag}
|
||||
title={cellTitle(b.tag, cls, st, isCurrent)}
|
||||
title={cellTitle(b.tag, cls, st, isCurrent) + (st ? ' — click to list the QSOs' : '')}
|
||||
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
|
||||
className={cn(
|
||||
'w-[28px] h-[24px] rounded transition-colors p-0',
|
||||
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
||||
// Only a filled cell has anything to show — an empty one
|
||||
// stays inert rather than opening a "no QSOs" dialog.
|
||||
st && 'cursor-pointer hover:brightness-110',
|
||||
isCurrent && 'ring-2 ring-warning ring-inset',
|
||||
)}
|
||||
/>
|
||||
@@ -277,6 +334,119 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{slot && (
|
||||
<SlotQSOModal
|
||||
call={wb?.callsign ?? ''}
|
||||
dxcc={dxcc}
|
||||
entity={dxccName}
|
||||
band={slot.band}
|
||||
cls={slot.cls}
|
||||
onClose={() => setSlot(null)}
|
||||
onEdit={onEditQso}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cell drill-down ──────────────────────────────────────────────────────────
|
||||
// The contacts behind one band+class cell: this exact callsign AND anyone else
|
||||
// in the entity, because that is the pair of facts the cell's colour encodes.
|
||||
// The call's own QSOs are marked so the two never blur together.
|
||||
|
||||
function SlotQSOModal({ call, dxcc, entity, band, cls, onClose, onEdit }: {
|
||||
call: string; dxcc: number; entity: string; band: string; cls: string; onClose: () => void; onEdit?: (id: number) => void;
|
||||
}) {
|
||||
const [rows, setRows] = useState<any[] | null>(null);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let dead = false;
|
||||
BandSlotQSOs(call, dxcc, band, cls)
|
||||
.then((r: any) => { if (!dead) setRows((r ?? []) as any[]); })
|
||||
.catch((e: any) => { if (!dead) { setErr(String(e?.message ?? e)); setRows([]); } });
|
||||
return () => { dead = true; };
|
||||
}, [call, dxcc, band, cls]);
|
||||
|
||||
useEffect(() => {
|
||||
// Capture phase: the app's global ESC handler resets the entry form, and
|
||||
// closing a dialog must not also wipe what the operator was typing.
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') { e.stopImmediatePropagation(); e.preventDefault(); onClose(); }
|
||||
}
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-[2px] p-4" onClick={onClose}>
|
||||
<div className="bg-card border border-border rounded-xl shadow-2xl w-[760px] max-w-full max-h-[72vh] flex flex-col overflow-hidden ring-1 ring-black/5"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2.5 px-4 py-2.5 border-b border-border bg-muted/30">
|
||||
<span className="inline-flex items-center justify-center h-6 min-w-6 px-1.5 rounded-md bg-primary/15 text-primary text-[11px] font-bold shrink-0 tabular-nums">{band}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-sm leading-tight truncate">
|
||||
{cls}{entity && <span className="text-muted-foreground font-normal"> · {entity}</span>}
|
||||
</div>
|
||||
{onEdit && <div className="text-[10px] text-muted-foreground leading-tight">Click a callsign to edit the QSO</div>}
|
||||
</div>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{rows ? `${rows.length} QSO${rows.length > 1 ? 's' : ''}` : ''}</span>
|
||||
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground shrink-0 rounded p-0.5 hover:bg-muted transition-colors">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{err && <p className="p-3 text-xs text-destructive">{err}</p>}
|
||||
{!rows ? (
|
||||
<p className="p-4 text-xs text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 className="size-3.5 animate-spin" /> Loading…
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="p-4 text-xs text-muted-foreground italic">No QSOs.</p>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-3 py-1.5">Date UTC</th>
|
||||
<th className="text-left font-medium px-3 py-1.5">Callsign</th>
|
||||
<th className="text-left font-medium px-3 py-1.5">Mode</th>
|
||||
<th className="text-left font-medium px-3 py-1.5">Freq</th>
|
||||
<th className="text-left font-medium px-3 py-1.5">Name</th>
|
||||
<th className="text-center font-medium px-3 py-1.5">Cfm</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((q, i) => {
|
||||
const cfm = q.lotw_rcvd === 'Y' || q.eqsl_rcvd === 'Y' || q.qsl_rcvd === 'Y';
|
||||
const mine = q.callsign === call;
|
||||
return (
|
||||
<tr key={q.id ?? i} className="border-t border-border/40 even:bg-muted/[0.06] hover:bg-primary/[0.06] transition-colors">
|
||||
<td className="px-3 py-1.5 tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{String(q.qso_date ?? '').slice(0, 16).replace('T', ' ')}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{onEdit ? (
|
||||
<button type="button" onClick={() => { onEdit(q.id as number); onClose(); }} title="Edit this QSO"
|
||||
className={cn('font-mono cursor-pointer text-left hover:underline underline-offset-2', mine ? 'font-bold text-primary' : 'text-foreground hover:text-primary')}>
|
||||
{q.callsign}
|
||||
</button>
|
||||
) : (
|
||||
<span className={cn('font-mono', mine && 'font-bold text-primary')}>{q.callsign}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">{q.mode}</td>
|
||||
<td className="px-3 py-1.5 tabular-nums text-muted-foreground">{q.freq_hz ? (q.freq_hz / 1e6).toFixed(3) : ''}</td>
|
||||
<td className="px-3 py-1.5 text-muted-foreground truncate max-w-[180px]">{q.name}</td>
|
||||
<td className="px-3 py-1.5 text-center">{cfm ? <span className="text-success">✓</span> : <span className="text-muted-foreground/30">·</span>}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ export type SpotStatusEntry = {
|
||||
worked_call?: boolean;
|
||||
new_county?: boolean;
|
||||
new_pota?: boolean;
|
||||
new_pfx?: boolean;
|
||||
pfx?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -99,50 +101,58 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
||||
];
|
||||
}
|
||||
|
||||
// statusBadge maps a resolved spot status to a short labelled badge for the
|
||||
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
||||
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
||||
// there's nothing notable to show.
|
||||
type Badge = { text: string; fg: string; bg: string; bd: string };
|
||||
// Spot status is shown by COLOURING THE TEXT, never by a pill or a badge.
|
||||
//
|
||||
// The pills were dropped: a rounded box has its own height and padding, so it
|
||||
// sat off the row's baseline and the callsign inside it no longer lined up with
|
||||
// the plain callsigns above and below. A whole column of them read as chrome
|
||||
// rather than as data. Same reasoning — and the same semantic tokens — as the
|
||||
// Y/N/R QSL columns in lib/qslStatus.ts.
|
||||
//
|
||||
// Which cell is coloured IS the message, so one colour is enough for all three:
|
||||
//
|
||||
// yellow call → new DXCC yellow band → new band yellow mode → new mode
|
||||
// blue call → already worked
|
||||
const NEW = 'var(--warning)'; // yellow: something here is new
|
||||
const WKD = 'var(--info)'; // blue: this callsign is already in the log
|
||||
|
||||
// tok builds a badge from a semantic status token (success/warning/caution/
|
||||
// danger/info/neutral) so the colours adapt to every theme via CSS variables,
|
||||
// instead of the hard-coded light-theme pastels that looked wrong in dark mode.
|
||||
function tok(name: string, text: string): Badge {
|
||||
if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' };
|
||||
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
|
||||
}
|
||||
|
||||
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
|
||||
// Status badges) when the slot is notable, instead of flooding the whole cell with
|
||||
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
|
||||
// null → plain text, no pill.
|
||||
function cellChip(value: any, name: string | null): any {
|
||||
// cellText renders a cell value in an optional colour. An empty value keeps the
|
||||
// muted dash the grid used before, so blank cells still read as "nothing here"
|
||||
// rather than as a gap.
|
||||
function cellText(value: any, color: string | null): any {
|
||||
const txt = value === undefined || value === null || value === '' ? '' : String(value);
|
||||
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||
// Inherit the column's font size (no fixed 9px / height) so a pill around a
|
||||
// callsign stays the same size as the plain callsigns next to it — just tinted
|
||||
// and rounded, not shrunk.
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', lineHeight: 1.1,
|
||||
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
|
||||
border: `1px solid var(--${name}-border)`, fontWeight: 700,
|
||||
padding: '1px 6px', borderRadius: 999, whiteSpace: 'nowrap',
|
||||
}}>{txt}</span>
|
||||
);
|
||||
if (!txt) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||
return <span style={color ? { color, fontWeight: 700 } : undefined}>{txt}</span>;
|
||||
}
|
||||
|
||||
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
|
||||
// statusColor is the colour for a resolved status in the Status column. County,
|
||||
// POTA and prefix keep their own tokens: they are orthogonal to the band/mode/
|
||||
// DXCC story and an operator filters on them separately.
|
||||
function statusColor(s: SpotStatusEntry | undefined): string | null {
|
||||
switch (s?.status) {
|
||||
case 'new': return tok('danger', t('clg2.newDxcc'));
|
||||
case 'new-band': return tok('warning', t('clg2.newBand'));
|
||||
case 'new-mode': return tok('caution', t('clg2.newMode'));
|
||||
case 'new-slot': return tok('caution', t('clg2.newSlot'));
|
||||
default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null;
|
||||
case 'new':
|
||||
case 'new-band':
|
||||
case 'new-mode':
|
||||
case 'new-slot':
|
||||
return NEW;
|
||||
default:
|
||||
return s?.worked_call ? WKD : null;
|
||||
}
|
||||
}
|
||||
|
||||
// isDull reports a spot that "represents nothing" under the current worked/slot
|
||||
// rules: the entity/band/mode is already worked (status resolved, not new in any
|
||||
// dimension), the callsign itself isn't in the log, and there's no POTA / county
|
||||
// / prefix novelty. Such rows are dimmed so the eye skips them. Because the
|
||||
// status obeys the DX-cluster "same slot" option and the digital-mode grouping,
|
||||
// what counts as dull follows those settings automatically. Unresolved statuses
|
||||
// (still loading, or entity unknown) are NOT dimmed — that would flicker.
|
||||
function isDull(s: SpotStatusEntry | undefined): boolean {
|
||||
if (!s || !s.status) return false;
|
||||
if (s.status === 'new' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot') return false;
|
||||
return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx);
|
||||
}
|
||||
|
||||
const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
{
|
||||
group: 'Spot', label: t('clg2.c.time'), colId: 'time',
|
||||
@@ -166,15 +176,12 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
// NEW DXCC → a danger pill around the call, consistent with the NEW BAND /
|
||||
// NEW MODE pills (same look, same tokens). Worked-call stays blue bold text;
|
||||
// a plain spot inherits the theme's normal text colour so callsigns blend in
|
||||
// with the rest of the row instead of always shouting a colour.
|
||||
// NEW DXCC → yellow call. Already worked → blue call. Anything else keeps
|
||||
// the theme's normal ink so ordinary callsigns don't shout.
|
||||
cellRenderer: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return cellChip(p.value, 'danger');
|
||||
const color = s?.worked_call ? 'var(--info)' : undefined;
|
||||
return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
|
||||
const color = s?.status === 'new' ? NEW : s?.worked_call ? WKD : null;
|
||||
return <span style={{ color: color ?? undefined, fontWeight: 700 }}>{p.value ?? ''}</span>;
|
||||
},
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
@@ -185,9 +192,10 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
||||
headerName: t('clg2.c.status'), width: 120, sortable: true,
|
||||
defaultVisible: true,
|
||||
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
||||
// is obvious at the row level, not just a single coloured cell. NEW COUNTY
|
||||
// and NEW POTA are orthogonal, so they stack as extra badges.
|
||||
// Spells the status out in words so NEW SLOT (and the others) is obvious at
|
||||
// the row level, not just a single coloured cell — NEW SLOT in particular
|
||||
// colours no cell at all, since neither the band nor the mode is new on its
|
||||
// own. NEW COUNTY and NEW POTA are orthogonal, so they stack after it.
|
||||
valueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
const parts: string[] = [];
|
||||
@@ -198,25 +206,31 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
|
||||
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
||||
if (s?.new_pota) parts.push(t('clg2.newPota'));
|
||||
if (s?.new_pfx) parts.push(t('clg2.newPfx'));
|
||||
return parts.join(' ');
|
||||
},
|
||||
cellRenderer: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
const badges: Badge[] = [];
|
||||
const b = statusBadge(t, s);
|
||||
if (b) badges.push(b);
|
||||
if (s?.new_county) badges.push(tok('info', t('clg2.newCounty')));
|
||||
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota')));
|
||||
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||
const parts: { text: string; color: string }[] = [];
|
||||
const main = statusColor(s);
|
||||
if (main) {
|
||||
const label = s?.status === 'new' ? t('clg2.newDxcc')
|
||||
: s?.status === 'new-band' ? t('clg2.newBand')
|
||||
: s?.status === 'new-mode' ? t('clg2.newMode')
|
||||
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
||||
: t('clg2.wkdCall');
|
||||
parts.push({ text: label, color: main });
|
||||
}
|
||||
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: 'var(--success)' });
|
||||
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: 'var(--success)' });
|
||||
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: 'var(--caution)' });
|
||||
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||
return (
|
||||
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
|
||||
{badges.map((bd, i) => (
|
||||
<span key={i} style={{
|
||||
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
|
||||
backgroundColor: bd.bg, color: bd.fg, border: `1px solid ${bd.bd}`,
|
||||
fontWeight: 700, fontSize: 9,
|
||||
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
||||
}}>{bd.text}</span>
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
{parts.map((pt, i) => (
|
||||
<span key={i} style={{ color: pt.color, fontWeight: 700 }}>
|
||||
{i > 0 ? ' · ' : ''}{pt.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
@@ -249,8 +263,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
// NEW BAND for this entity → small warning pill around the band text.
|
||||
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null),
|
||||
// NEW BAND for this entity → the band text turns yellow.
|
||||
cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-band' ? NEW : null),
|
||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
||||
},
|
||||
{
|
||||
@@ -263,7 +277,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
// for the entity. NEW SLOT means band AND mode were each worked before (just
|
||||
// not together), so highlighting the mode cell would wrongly imply "CW is new";
|
||||
// that case is signalled by the Status badge alone.
|
||||
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null),
|
||||
cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-mode' ? NEW : null),
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||
@@ -396,6 +410,11 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||
// Dim "represents nothing" spots at the CELL level, via a class that
|
||||
// refreshCells re-applies. Doing it with a row STYLE needed redrawRows() on
|
||||
// every status update, which re-rendered the whole grid continuously and
|
||||
// pegged the CPU on a busy cluster (the 0.23.6 slowdown).
|
||||
cellClassRules: { 'opacity-40': (p: any) => isDull(statusFor(p)) },
|
||||
}), []);
|
||||
|
||||
// Pass spotStatus through AG Grid's context so cell renderers can look up
|
||||
@@ -405,12 +424,22 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
||||
|
||||
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
|
||||
// Mode cellStyles depend on them but their cell VALUE doesn't change, so ag-grid
|
||||
// won't re-render those cells on its own — force a refresh so e.g. a worked call
|
||||
// turns blue once its status loads.
|
||||
// Mode cellStyles and the dimmed "represents nothing" class depend on them but
|
||||
// the cell VALUE doesn't change, so ag-grid won't re-render on its own — force a
|
||||
// refresh so e.g. a worked call turns blue once its status loads.
|
||||
//
|
||||
// THROTTLED: under an RBN firehose spotStatus updates ~20×/second, and firing a
|
||||
// full refreshCells that often is pure churn on a slow PC. Coalesce the bursts
|
||||
// into one refresh every 200 ms (still imperceptible) instead of one per update.
|
||||
const refreshPending = useRef<number | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
gridRef.current?.api?.refreshCells({ force: true });
|
||||
if (refreshPending.current !== undefined) return; // a refresh is already queued
|
||||
refreshPending.current = window.setTimeout(() => {
|
||||
refreshPending.current = undefined;
|
||||
gridRef.current?.api?.refreshCells({ force: true });
|
||||
}, 200);
|
||||
}, [spotStatus]);
|
||||
useEffect(() => () => { if (refreshPending.current !== undefined) window.clearTimeout(refreshPending.current); }, []);
|
||||
|
||||
// Restore AFTER the profile scope is known — this grid has no key= remount to
|
||||
// save it from reading the wrong (unscoped) cache key at first paint.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
||||
import { ComputeQSOAwardRefs, IsNewUSCounty } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -53,6 +53,14 @@ interface Props {
|
||||
prefix: string;
|
||||
operatorGrid: string; // station.my_grid — origin for bearing/distance
|
||||
remoteGrid: string; // entry-strip Grid value — destination
|
||||
// Entry-strip text fields. Live award detection matches on these too — a DARC
|
||||
// DOK keys off QTH, not the address — and sending only the address meant such
|
||||
// an award stayed silent until the QSO was logged.
|
||||
qth?: string;
|
||||
name?: string;
|
||||
country?: string;
|
||||
comment?: string;
|
||||
note?: string;
|
||||
details: DetailsState;
|
||||
onChange: (patch: Partial<DetailsState>) => void;
|
||||
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
|
||||
@@ -61,6 +69,14 @@ interface Props {
|
||||
band: string;
|
||||
mode: string;
|
||||
bands?: string[]; // configured bands for the worked-before matrix columns
|
||||
// When the entry form is empty and a QSO is selected in the log grid, the
|
||||
// Stats (F1) matrix shows THAT contact's entity instead of sitting blank.
|
||||
// Only the matrix is redirected — every other tab still edits the live entry.
|
||||
slotCall?: string;
|
||||
slotBand?: string;
|
||||
slotMode?: string;
|
||||
slotWb?: any;
|
||||
slotWbBusy?: boolean;
|
||||
imageUrl?: string;
|
||||
onOpenImage?: () => void;
|
||||
// Optional controlled active tab (so the app can switch it via keyboard).
|
||||
@@ -69,6 +85,8 @@ interface Props {
|
||||
// When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is
|
||||
// shown as Ctrl+F1…F5 instead of F1…F5.
|
||||
keyerActive?: boolean;
|
||||
// Open the QSO editor — makes callsigns clickable in the band-slot drill-down.
|
||||
onEditQso?: (id: number) => void;
|
||||
}
|
||||
|
||||
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||
@@ -113,28 +131,57 @@ function numOrUndef(v: string): number | undefined {
|
||||
return isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
// Column spans written out rather than built as `col-span-${n}`: Tailwind scans
|
||||
// for literal class names, so an interpolated one is never emitted.
|
||||
const SPAN_CLASS: Record<number, string> = {
|
||||
1: '', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4', 5: 'col-span-5',
|
||||
6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8', 9: 'col-span-9',
|
||||
10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12',
|
||||
};
|
||||
|
||||
// Compact field helper to keep the JSX dense.
|
||||
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 4 | 6; children: React.ReactNode }) {
|
||||
function Field({ label, span = 1, className, children }: { label: string; span?: number; className?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 4 && 'col-span-4', span === 6 && 'col-span-6')}>
|
||||
<div className={cn('flex flex-col min-w-0', SPAN_CLASS[span], className)}>
|
||||
<Label className="mb-1">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||
|
||||
// Live award detection: run the SAME engine used at log time over the current
|
||||
// contact (callsign + looked-up address/state/zones) so award references the
|
||||
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
|
||||
// surfaced and auto-added the moment you enter a call or click a spot, instead
|
||||
// of only appearing after logging. Pickable matches are merged into award_refs
|
||||
// contact — callsign, the looked-up address/state/zones AND the entry-strip
|
||||
// text (QTH, name, country, comment, note, grid). Sending only the address was
|
||||
// a silent hole: a DARC DOK matches on QTH, so "Aachen" produced nothing here
|
||||
// while the award editor's Test tab, which passes the whole QSO, matched it.
|
||||
// References the QSO will earn — WAPC matching "Beijing" inside the address,
|
||||
// a DOK matching the town — are surfaced and auto-added the moment you enter a
|
||||
// call or click a spot, instead of only appearing after logging. Pickable
|
||||
// matches are merged into award_refs
|
||||
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
|
||||
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean }>>([]);
|
||||
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean; ambiguous?: boolean }>>([]);
|
||||
|
||||
// NEW badge on the county: the same question the cluster's NEW CTY badge
|
||||
// answers, for the station being worked right now. Debounced because the
|
||||
// county field is typed into as well as filled by the lookup.
|
||||
const [newCounty, setNewCounty] = useState(false);
|
||||
useEffect(() => {
|
||||
const cnty = (details.cnty ?? '').trim();
|
||||
if (!cnty) { setNewCounty(false); return; }
|
||||
let alive = true;
|
||||
const id = window.setTimeout(async () => {
|
||||
try {
|
||||
const isNew = await IsNewUSCounty(details.state ?? '', cnty);
|
||||
if (alive) setNewCounty(!!isNew);
|
||||
} catch { if (alive) setNewCounty(false); }
|
||||
}, 300);
|
||||
return () => { alive = false; window.clearTimeout(id); };
|
||||
}, [details.state, details.cnty]);
|
||||
useEffect(() => {
|
||||
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
|
||||
const t = window.setTimeout(async () => {
|
||||
@@ -142,6 +189,8 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
const q: any = {
|
||||
callsign, band, mode,
|
||||
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
|
||||
qth: qth ?? '', name: name ?? '', country: country ?? '',
|
||||
comment: comment ?? '', notes: note ?? '', grid: remoteGrid ?? '',
|
||||
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
|
||||
qso_date: new Date().toISOString(),
|
||||
};
|
||||
@@ -151,7 +200,9 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
const have = new Set(cur.split(';').filter(Boolean));
|
||||
let next = cur;
|
||||
for (const r of all) {
|
||||
if (!r.pickable) continue;
|
||||
// An ambiguous candidate is offered, never auto-added: adding one would
|
||||
// be the guess the award's "one reference per QSO" setting exists to refuse.
|
||||
if (!r.pickable || r.ambiguous) continue;
|
||||
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
|
||||
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
|
||||
}
|
||||
@@ -160,7 +211,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
}, 400);
|
||||
return () => window.clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, band, mode]);
|
||||
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, qth, name, country, comment, note, remoteGrid, band, mode]);
|
||||
// Bearing/distance from operator's home grid to the remote station.
|
||||
// Recomputed only when either grid actually changes.
|
||||
const path = useMemo(() => {
|
||||
@@ -174,6 +225,15 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
}
|
||||
return null;
|
||||
}, [operatorGrid, remoteGrid, details.lat, details.lon]);
|
||||
// Where the DX actually is, for its sunrise/sunset. The locator wins when we
|
||||
// have one — it is what the operator can see and check — and the provider's
|
||||
// raw coordinates cover entities that resolve through cty.dat with no grid.
|
||||
const dxLL = useMemo(() => {
|
||||
const byGrid = gridToLatLon(remoteGrid);
|
||||
if (byGrid) return byGrid;
|
||||
if (details.lat != null && details.lon != null) return { lat: details.lat, lon: details.lon };
|
||||
return null;
|
||||
}, [remoteGrid, details.lat, details.lon]);
|
||||
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
||||
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
||||
|
||||
@@ -222,19 +282,40 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
|
||||
{open === 'stats' && (
|
||||
<div className="px-3 py-2.5">
|
||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
|
||||
<BandSlotGrid
|
||||
wb={slotCall ? slotWb : wb}
|
||||
busy={slotCall ? !!slotWbBusy : !!wbBusy}
|
||||
currentBand={slotCall ? (slotBand ?? '') : band}
|
||||
currentMode={slotCall ? (slotMode ?? '') : mode}
|
||||
bands={bands}
|
||||
hasCall={slotCall ? true : callsign.trim() !== ''}
|
||||
forCall={slotCall}
|
||||
onEditQso={onEditQso}
|
||||
lat={dxLL?.lat} lon={dxLL?.lon} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open === 'info' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label={t('detp.statePref')}>
|
||||
<div className="grid grid-cols-12 gap-2 px-3 py-2.5">
|
||||
{/* Twelve columns, not six: the fields hold very different things. A US
|
||||
county name is long, a CQ zone is two digits — on an even six-column
|
||||
grid they were the same width, so the county truncated while the
|
||||
zones sat mostly empty. */}
|
||||
<Field label={t('detp.statePref')} span={2}>
|
||||
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.county')}>
|
||||
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
|
||||
<Field label={t('detp.county')} span={4} className="relative">
|
||||
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} className={cn(newCounty && 'pr-14')} />
|
||||
{newCounty && (
|
||||
<span
|
||||
title={t('detp.newCountyTip')}
|
||||
className="absolute right-1.5 bottom-1.5 rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wide bg-success text-success-foreground pointer-events-none"
|
||||
>
|
||||
{t('detp.newCounty')}
|
||||
</span>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t('detp.prefix')}>
|
||||
<Field label={t('detp.prefix')} span={2}>
|
||||
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
||||
</Field>
|
||||
<Field label={t('detp.cqZone')}>
|
||||
@@ -248,7 +329,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
{/* DXCC # closes the top row (next to the zones); Continent and
|
||||
Azimuth SP live in the main entry strip / bandeau. The long-path
|
||||
bearing and distances move to the row below. */}
|
||||
<Field label={t('detp.dxcc')}>
|
||||
<Field label={t('detp.dxcc')} span={2}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -257,40 +338,45 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.azimuthLp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtDeg(path.bearingLong) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.distanceSp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtKm(path.distanceShort) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.distanceLp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtKm(path.distanceLong) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.address')} span={3}>
|
||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.qslMessage')} span={3}>
|
||||
{/* The bearing and the two distances are read-only and hold at most
|
||||
"12345 km", so they take a fixed width and the address — the one
|
||||
field here that is never wide enough — gets everything left over. */}
|
||||
<div className="col-span-12 flex gap-2">
|
||||
<Field label={t('detp.azimuthLp')} className="w-20 shrink-0">
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtDeg(path.bearingLong) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.distanceSp')} className="w-24 shrink-0">
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtKm(path.distanceShort) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.distanceLp')} className="w-24 shrink-0">
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtKm(path.distanceLong) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.address')} className="flex-1 min-w-0">
|
||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t('detp.qslMessage')} span={7}>
|
||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.qslVia')} span={2}>
|
||||
<Field label={t('detp.qslVia')} span={5}>
|
||||
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
@@ -305,10 +391,37 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
|
||||
heightClass="flex-1 min-h-0"
|
||||
/>
|
||||
{detected.length > 0 && (
|
||||
{/* Ambiguous candidates: the award allows one reference per QSO and
|
||||
several matched, so none was kept. Click one to settle it while the
|
||||
contact is still in front of you. */}
|
||||
{detected.some((r) => r.ambiguous) && (
|
||||
<div className="mt-2 text-[11px] shrink-0 leading-snug border-t border-warning/40 pt-1.5">
|
||||
<span className="font-medium text-warning">{t('detp.ambiguous')}</span>{' '}
|
||||
{detected.filter((r) => r.ambiguous).map((r) => (
|
||||
<button
|
||||
key={`amb-${r.code}@${r.ref}`}
|
||||
type="button"
|
||||
title={r.name ?? ''}
|
||||
onClick={() => {
|
||||
const entry = `${r.code.toUpperCase()}@${r.ref.toUpperCase()}`;
|
||||
const cur = details.award_refs ?? '';
|
||||
// Only one of the candidates can be right — replace any
|
||||
// sibling already picked for this award rather than stacking.
|
||||
const kept = cur.split(';').filter((e) => e && e.split('@')[0].toUpperCase() !== r.code.toUpperCase());
|
||||
onChange({ award_refs: [...kept, entry].join(';') });
|
||||
setDetected((d) => d.filter((x) => !x.ambiguous || x.code.toUpperCase() !== r.code.toUpperCase()));
|
||||
}}
|
||||
className="inline-block mr-1.5 px-1.5 py-0.5 rounded font-mono whitespace-nowrap border border-warning/50 text-warning hover:bg-warning/10"
|
||||
>
|
||||
{r.code}@{r.ref}{r.name ? ` — ${r.name}` : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{detected.some((r) => !r.ambiguous) && (
|
||||
<div className="mt-2 text-[11px] text-muted-foreground shrink-0 max-h-14 overflow-y-auto leading-snug border-t border-border/50 pt-1.5">
|
||||
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
||||
{detected.map((r) => (
|
||||
{detected.filter((r) => !r.ambiguous).map((r) => (
|
||||
<span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
|
||||
<span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import { LogUIError } from '../../wailsjs/go/main/App';
|
||||
|
||||
// A render error used to empty the window and leave nothing behind: no line in
|
||||
// opslog.log, no dialog, no way for the operator to say more than "white screen".
|
||||
// Two separate reports — one after logging a 10 GHz QSO, one on starting a DVK
|
||||
// auto-call — stayed undiagnosed for weeks for exactly that reason.
|
||||
//
|
||||
// So this does two things, in order of importance:
|
||||
//
|
||||
// 1. Writes the error and its stack to the app log through LogUIError, so the
|
||||
// next report arrives WITH the cause instead of a description of a blank
|
||||
// screen.
|
||||
// 2. Shows it, with the text selectable and a button to copy it, because the
|
||||
// person who can act on it is the one looking at the screen.
|
||||
//
|
||||
// It deliberately does NOT try to recover the tree. React cannot guarantee the
|
||||
// state is sane after a render throw, and a half-working logger is worse than
|
||||
// one that says plainly what happened and offers a reload.
|
||||
|
||||
type Props = { children: React.ReactNode };
|
||||
type State = { error: Error | null; info: string };
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null, info: '' };
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
const stack = (info?.componentStack || error.stack || '').trim();
|
||||
this.setState({ info: stack });
|
||||
// Never let the reporting path throw: it runs while the app is already broken.
|
||||
try {
|
||||
LogUIError('render crash', `${error.name}: ${error.message}`, stack);
|
||||
} catch {
|
||||
/* the backend may be gone too — the on-screen copy still works */
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error, info } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
|
||||
const report = `${error.name}: ${error.message}\n\n${info}`;
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24, background: '#0b1220', color: '#e5e7eb', fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
overflow: 'auto', zIndex: 9999,
|
||||
}}>
|
||||
<div style={{ maxWidth: 820, width: '100%' }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 6px' }}>
|
||||
OpsLog hit an error and stopped drawing
|
||||
</h1>
|
||||
<p style={{ fontSize: 13, opacity: 0.8, margin: '0 0 14px' }}>
|
||||
Your log is safe — this is the window, not the database. The details below
|
||||
are already in the app log; send them with your report.
|
||||
</p>
|
||||
<pre style={{
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word', userSelect: 'text',
|
||||
fontSize: 11.5, lineHeight: 1.5, background: '#111827', border: '1px solid #1f2937',
|
||||
borderRadius: 8, padding: 12, maxHeight: '48vh', overflow: 'auto', margin: 0,
|
||||
}}>{report}</pre>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
|
||||
<button
|
||||
onClick={() => { void navigator.clipboard?.writeText(report); }}
|
||||
style={{
|
||||
padding: '7px 14px', borderRadius: 6, border: '1px solid #374151',
|
||||
background: '#1f2937', color: '#e5e7eb', fontSize: 13, cursor: 'pointer',
|
||||
}}>
|
||||
Copy the details
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
padding: '7px 14px', borderRadius: 6, border: '1px solid #1d4ed8',
|
||||
background: '#2563eb', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
}}>
|
||||
Reload the window
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// installGlobalErrorLogging catches what a boundary cannot: an error thrown from
|
||||
// a timer, an event handler or a rejected promise. Those do not unmount the tree,
|
||||
// so they leave no trace at all — and an auto-call loop lives entirely in
|
||||
// callbacks, which is precisely where a white screen was reported.
|
||||
export function installGlobalErrorLogging() {
|
||||
const report = (kind: string, message: string, stack?: string) => {
|
||||
try {
|
||||
LogUIError(kind, message, stack || '');
|
||||
} catch {
|
||||
/* nothing more we can do from here */
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('error', (e: ErrorEvent) => {
|
||||
const src = e.filename ? ` (${e.filename}:${e.lineno}:${e.colno})` : '';
|
||||
report('uncaught error', (e.message || 'unknown error') + src, e.error?.stack);
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
||||
const r: any = e.reason;
|
||||
report('unhandled rejection', String(r?.message ?? r ?? 'unknown'), r?.stack);
|
||||
});
|
||||
}
|
||||
@@ -234,7 +234,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
<SelectItem key={n} value={n}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{n}
|
||||
<Trash2 className="size-3 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); deletePreset(n); }} />
|
||||
{/* Radix SelectItem selects on pointer-up, so an onClick here
|
||||
fires too late — the menu has already loaded the preset and
|
||||
closed. Intercept pointer-down (preventDefault stops Radix
|
||||
claiming it) and delete there instead. */}
|
||||
<Trash2
|
||||
className="size-3 text-muted-foreground hover:text-destructive"
|
||||
onPointerDown={(e) => { e.preventDefault(); e.stopPropagation(); deletePreset(n); }}
|
||||
/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
@@ -323,13 +323,18 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [st.cw_speed]);
|
||||
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
||||
// read steadily instead of jumping every poll.
|
||||
// Peak-hold: keep the highest reading for a second so the jittery VITA-49
|
||||
// meters read steadily instead of jumping every poll. One second, not two —
|
||||
// longer and the meters visibly trail the end of a transmission, which reads
|
||||
// as the app lagging rather than as a peak being held.
|
||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||
const peakHold = (key: string, val: number) => {
|
||||
const peakHold = (key: string, val: number, windowMs = 1000, live = true) => {
|
||||
// live=false throws the held value away at once (e.g. the carrier dropped),
|
||||
// so the meter falls immediately instead of coasting for the window.
|
||||
if (!live) { delete peak.current[key]; return val; }
|
||||
const now = Date.now();
|
||||
const p = peak.current[key];
|
||||
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
|
||||
@@ -355,12 +360,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
}, []);
|
||||
|
||||
// PowerGenius XL direct connection (fan mode), independent of the Flex link.
|
||||
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string }>({ connected: false });
|
||||
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string; state?: string; peak_w?: number }>({ connected: false });
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
|
||||
tick();
|
||||
const id = window.setInterval(tick, 2000);
|
||||
const id = window.setInterval(tick, 250); // match the amp's 250 ms poll so the card catches every envelope peak
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
@@ -978,10 +983,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */}
|
||||
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
|
||||
is off (no status = not "connected"), and serial only. */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!spe.connected}
|
||||
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
|
||||
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
|
||||
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
|
||||
@@ -1083,9 +1090,6 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
st.amp_operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{st.amp_operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
||||
</span>
|
||||
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
||||
PowerGenius). The dot shows the direct-connection state; the
|
||||
selector is disabled until connected (hover it for the error). */}
|
||||
@@ -1113,11 +1117,38 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
|
||||
and shown compact so they sit with the amp controls. */}
|
||||
{(() => {
|
||||
// Prefer the radio's VITA amp meters when they are flowing (local or
|
||||
// over a VPN) — fast, and the same values SmartSDR shows (incl. the
|
||||
// right temperature sensor). Fall back to the amp's OWN link only when
|
||||
// the radio isn't streaming them (a remote link without VITA).
|
||||
const meters = st.meters || [];
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||
if (off || amp.length === 0) return null;
|
||||
if (off || amp.length === 0) {
|
||||
const P = pg as any;
|
||||
if (!P.connected) return null;
|
||||
// Direct-link fallback: a 500 ms poll samples the SSB envelope at
|
||||
// random points, so peak-hold it (live=txing drops it the instant
|
||||
// PTT releases). The LDMOS pair can pull well past 25 A → 50 A scale.
|
||||
const txing = typeof st.transmitting === 'boolean' ? st.transmitting : /TRANSMIT/i.test(P.state || '');
|
||||
const w = txing ? Math.round(Number(P.fwd_w) || 0) : 0;
|
||||
const idA = txing ? Number(P.id) || 0 : 0;
|
||||
const temp = Number(P.temperature) || 0;
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||
<MeterBar label={t('ampw.pwr')} value={peakHold('pgw', w, 2500, txing)} unit="W" lo={0} hi={2000} accent="#dc2626" />
|
||||
<MeterBar label={t('ampw.id')} value={peakHold('pgid', idA, 2500, txing)} unit="A" lo={0} hi={50} accent="#16a34a" />
|
||||
<MeterBar label={t('ampw.temp')} value={temp} unit="°C" lo={0} hi={80} accent="#ea580c" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Power comes from the radio's meter stream and nothing else. The
|
||||
// amplifier also reports a "peakfwd", and using it was a mistake
|
||||
// twice over: it is a latched maximum that is never reset, and it
|
||||
// survives in the last-known status after the amp disconnects — so
|
||||
// it claimed 1350 W from an old transmission while the radio was
|
||||
// putting out 10.
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||
{amp.map((m) => {
|
||||
@@ -1126,14 +1157,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
}
|
||||
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||
// Drain current (ID): the PGXL reports a full-scale far too small
|
||||
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
|
||||
// value itself is fine — only the bar's range was wrong. Give it a
|
||||
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
|
||||
// only override an unusable reported range, not a sane one.
|
||||
// for this meter, so it pinned the bar. Give it a fixed 50 A scale
|
||||
// (the LDMOS pair can pull ~45-50 A near full output), overriding an
|
||||
// unusable reported range but keeping a sane one.
|
||||
let lo = m.lo, hi = m.hi;
|
||||
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
||||
lo = 0;
|
||||
hi = m.hi >= 25 ? m.hi : 25;
|
||||
hi = m.hi >= 50 ? m.hi : 50;
|
||||
}
|
||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { TailLogFile, GetLogFilePath } from '../../wailsjs/go/main/App';
|
||||
|
||||
// LogViewer shows the tail of opslog.log and keeps it current while open.
|
||||
//
|
||||
// This exists because diagnosing a problem used to mean asking the operator to
|
||||
// find a file in their data directory, open it in a text editor, and re-open it
|
||||
// after every attempt. Half the round-trips in a bug report were spent on that.
|
||||
//
|
||||
// Three behaviours matter more than they look:
|
||||
// • Auto-scroll is a checkbox the operator owns, AND it releases itself the
|
||||
// moment they scroll up — otherwise the line they are reading is yanked away
|
||||
// a second later, which defeats the one thing this window is for. Scrolling
|
||||
// back to the bottom re-arms it, so following is never a dead end.
|
||||
// • The filter keeps only matching LINES rather than highlighting inside a
|
||||
// 256 KB blob: with "cluster" or "acom" typed in, the window becomes exactly
|
||||
// the subsystem trace you would have grepped for.
|
||||
// • It polls only while open. A closed dialog costs nothing.
|
||||
// 512 KB ≈ 3200 lines. The window is a sliding tail: once new lines push past it
|
||||
// the oldest fall out of the fetched text entirely, so a small buffer dropped
|
||||
// lines the operator was still reading during a busy trace (CAT/cluster/antenna
|
||||
// polling). This is double the old 256 KB — 1 MB was tried but the per-second
|
||||
// fetch + split + re-render of ~6500 lines made the window lag. 512 KB keeps a
|
||||
// useful backlog while staying smooth. The backend caps this at 4 MB.
|
||||
const TAIL_BYTES = 512 * 1024;
|
||||
const POLL_MS = 1000;
|
||||
|
||||
export function LogViewer({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [text, setText] = useState('');
|
||||
const [path, setPath] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
// Shown next to the checkbox so a QUIET log still proves it is being polled —
|
||||
// without it there is no way to tell 'nothing new' from 'window is dead'.
|
||||
const [lastPull, setLastPull] = useState('');
|
||||
const boxRef = useRef<HTMLPreElement>(null);
|
||||
const autoRef = useRef(true);
|
||||
useEffect(() => { autoRef.current = autoScroll; }, [autoScroll]);
|
||||
|
||||
// stick pins the view to the bottom on the NEXT frame. Setting scrollTop
|
||||
// straight away is not enough while the dialog is still animating in: the box
|
||||
// has no height yet, scrollHeight is wrong, and the scroll silently does
|
||||
// nothing — leaving the operator staring at the top of a 256 KB buffer, i.e.
|
||||
// at lines from hours ago, which looks exactly like a frozen window.
|
||||
const stick = useCallback(() => {
|
||||
if (!autoRef.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
const el = boxRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pull = useCallback(() => {
|
||||
TailLogFile(TAIL_BYTES).then((s: any) => {
|
||||
setText(String(s ?? ''));
|
||||
setLastPull(new Date().toLocaleTimeString());
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setAutoScroll(true);
|
||||
autoRef.current = true;
|
||||
GetLogFilePath().then((p: any) => setPath(String(p ?? ''))).catch(() => {});
|
||||
pull();
|
||||
// Re-assert the bottom after the open animation has settled. A quiet log
|
||||
// never changes, so the content-driven scroll below would never fire again
|
||||
// and the first attempt would be the only one.
|
||||
const t1 = window.setTimeout(stick, 120);
|
||||
const t2 = window.setTimeout(stick, 450);
|
||||
const id = window.setInterval(pull, POLL_MS);
|
||||
return () => { window.clearInterval(id); window.clearTimeout(t1); window.clearTimeout(t2); };
|
||||
}, [open, pull, stick]);
|
||||
|
||||
const all = useMemo(() => text.split('\n'), [text]);
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter((l) => l.toLowerCase().includes(q));
|
||||
}, [all, query]);
|
||||
|
||||
// Stick to the bottom after each refresh, but only while auto-scroll is on.
|
||||
useEffect(() => { if (open) stick(); }, [shown, open, stick]);
|
||||
|
||||
// Scrolling away from the bottom releases auto-scroll; coming back re-arms it.
|
||||
function onScroll() {
|
||||
const el = boxRef.current;
|
||||
if (!el) return;
|
||||
setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 24);
|
||||
}
|
||||
|
||||
const body = shown.join('\n');
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('logview.title')}</DialogTitle>
|
||||
<DialogDescription className="font-mono text-[11px] break-all">{path}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 flex-1 font-mono text-xs" value={query} placeholder={t('logview.searchPh')}
|
||||
onChange={(e) => setQuery(e.target.value)} />
|
||||
{query.trim() !== '' && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||
{t('logview.matches', { n: shown.length, total: all.length })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<pre ref={boxRef} onScroll={onScroll}
|
||||
className="h-[60vh] overflow-auto rounded-md border border-border bg-muted/30 p-2 text-[11px] leading-snug font-mono whitespace-pre-wrap break-all">
|
||||
{body.trim() === '' ? t('logview.empty') : body}
|
||||
</pre>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<Checkbox checked={autoScroll} onCheckedChange={(c) => {
|
||||
const on = !!c;
|
||||
setAutoScroll(on);
|
||||
if (on) { const el = boxRef.current; if (el) el.scrollTop = el.scrollHeight; }
|
||||
}} />
|
||||
{t('logview.autoScroll')}
|
||||
</label>
|
||||
{lastPull && <span className="text-xs text-muted-foreground tabular-nums">{t('logview.updated', { time: lastPull })}</span>}
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" size="sm" onClick={() => navigator.clipboard?.writeText(body)}>
|
||||
{t('logview.copy')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setQuery(''); setAutoScroll(true); pull(); }}>
|
||||
{t('logview.toEnd')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { nightPolygon } from '../lib/greyline';
|
||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
@@ -115,6 +116,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
||||
// across restarts. Default on.
|
||||
const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0');
|
||||
|
||||
// Grey line — the day/night terminator with its twilight band. Off by
|
||||
// default: it is a working tool for the operator who wants it, not decoration
|
||||
// for the one who does not.
|
||||
const [greyline, setGreyline] = useState(() => localStorage.getItem('opslog.mapGreyline') === '1');
|
||||
const greylineLayer = useRef<L.LayerGroup | null>(null);
|
||||
const autoZoomRef = useRef(autoZoom);
|
||||
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
||||
|
||||
@@ -152,6 +159,80 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||
}, [basemap]);
|
||||
|
||||
// Draw the grey line, and keep it moving.
|
||||
//
|
||||
// Redrawn every minute: the terminator travels a quarter of a degree in that
|
||||
// time, which is invisible, but a map left on all day would otherwise be
|
||||
// silently wrong by evening — and the whole point of the display is knowing
|
||||
// where the line is NOW.
|
||||
//
|
||||
// It lives in its own pane below the overlay pane so the path and beam are
|
||||
// never hidden behind the shading, and is non-interactive so it can never
|
||||
// swallow a click meant for the map.
|
||||
useEffect(() => {
|
||||
const m = worldMap.current;
|
||||
if (!m) return;
|
||||
|
||||
if (!greyline) {
|
||||
greylineLayer.current?.remove();
|
||||
greylineLayer.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m.getPane('greyline')) {
|
||||
const pane = m.createPane('greyline');
|
||||
pane.style.zIndex = '350'; // above tiles (200), below overlays (400)
|
||||
pane.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
const now = new Date();
|
||||
greylineLayer.current?.remove();
|
||||
const g = L.layerGroup([], { pane: 'greyline' });
|
||||
|
||||
// The rings span −180…+180 once, but this map repeats the world sideways
|
||||
// (worldCopyJump, and zoomed out several copies are on screen at once). A
|
||||
// single ring therefore ends in a hard vertical edge wherever a copy
|
||||
// begins — the shading looked like a rectangle laid over the planet.
|
||||
//
|
||||
// So each ring is drawn again shifted a full turn either way. Three copies
|
||||
// cover every zoom level this map reaches; they cost nothing, being three
|
||||
// polygons on a canvas layer.
|
||||
const COPIES = [-360, 0, 360];
|
||||
const shifted = (ring: [number, number][], by: number): [number, number][] =>
|
||||
by === 0 ? ring : ring.map(([lat, lon]) => [lat, lon + by] as [number, number]);
|
||||
|
||||
// Two bands, drawn dark-to-light so they read as one gradient into night:
|
||||
// full darkness (Sun below the horizon) and civil twilight (down to −6°),
|
||||
// which is the band operators actually mean by "grey line".
|
||||
const nightRing = nightPolygon(now, 0);
|
||||
const twilightRing = nightPolygon(now, -6);
|
||||
// The terminator itself, so the line is readable at a glance rather than
|
||||
// being inferred from where the shading fades. Dropping the last two
|
||||
// points leaves the terminator alone, without the segment that closes the
|
||||
// polygon along the dark pole.
|
||||
const lineRing = nightRing.slice(0, -2);
|
||||
|
||||
for (const by of COPIES) {
|
||||
L.polygon(shifted(twilightRing, by), {
|
||||
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.22, interactive: false,
|
||||
}).addTo(g);
|
||||
L.polygon(shifted(nightRing, by), {
|
||||
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.32, interactive: false,
|
||||
}).addTo(g);
|
||||
L.polyline(shifted(lineRing, by), {
|
||||
pane: 'greyline', color: '#f59e0b', weight: 1, opacity: 0.75, interactive: false,
|
||||
}).addTo(g);
|
||||
}
|
||||
g.addTo(m);
|
||||
greylineLayer.current = g;
|
||||
};
|
||||
|
||||
draw();
|
||||
const id = window.setInterval(draw, 60_000);
|
||||
return () => { window.clearInterval(id); };
|
||||
}, [greyline]);
|
||||
|
||||
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||
useEffect(() => {
|
||||
const wm = worldMap.current, wo = worldOverlay.current;
|
||||
@@ -293,6 +374,21 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
>
|
||||
Zoom DX
|
||||
</button>
|
||||
{/* Grey line toggle, under the zoom button. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const v = !greyline;
|
||||
setGreyline(v);
|
||||
writeUiPref('opslog.mapGreyline', v ? '1' : '0');
|
||||
}}
|
||||
title={greyline ? 'Grey line is ON — day/night terminator and twilight band' : 'Show the grey line (day/night terminator)'}
|
||||
className={`absolute top-9 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||||
greyline ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
|
||||
}`}
|
||||
>
|
||||
Grey line
|
||||
</button>
|
||||
{path && (
|
||||
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
||||
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
@@ -120,6 +120,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
|
||||
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
||||
}, [service]);
|
||||
// Closing the QSL Manager (the tab's ×) unmounts this panel — cancel any download
|
||||
// still running so a slow QRZ/LoTW sync doesn't keep going in the background and
|
||||
// bleed its log into the next one.
|
||||
useEffect(() => () => { CancelConfirmations().catch(() => {}); }, []);
|
||||
const [potaSyncing, setPotaSyncing] = useState(false);
|
||||
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
||||
const [potaErr, setPotaErr] = useState('');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
@@ -38,6 +38,12 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
||||
// which used to dismiss the menu the instant it appeared.)
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||
const { t } = useI18n();
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
// Starts at the cursor; the layout effect corrects it once the real size is
|
||||
// known. Rendering at the cursor first avoids a visible jump for the common
|
||||
// case where it already fits.
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
useEffect(() => { if (menu) setPos({ x: menu.x, y: menu.y }); }, [menu]);
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const close = () => onClose();
|
||||
@@ -50,16 +56,40 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
};
|
||||
}, [menu, onClose]);
|
||||
|
||||
// Position AFTER measuring.
|
||||
//
|
||||
// The menu used to be clamped against a guessed height — 230 px when the
|
||||
// upload submenu was present, 110 otherwise. It is far taller than that with
|
||||
// a selection, so right-clicking near the bottom of the window cut half the
|
||||
// entries off. Its height also depends on which actions the caller passes, so
|
||||
// no constant can be right for long.
|
||||
//
|
||||
// Measured instead: if it does not fit below the cursor it is placed above,
|
||||
// and failing that pinned to the top with its own scrollbar.
|
||||
useLayoutEffect(() => {
|
||||
if (!menu || !boxRef.current) return;
|
||||
const r = boxRef.current.getBoundingClientRect();
|
||||
const pad = 6;
|
||||
let x = menu.x;
|
||||
let y = menu.y;
|
||||
if (x + r.width > window.innerWidth - pad) {
|
||||
x = Math.max(pad, window.innerWidth - r.width - pad);
|
||||
}
|
||||
if (y + r.height > window.innerHeight - pad) {
|
||||
// Above the cursor, if there is room there.
|
||||
y = menu.y - r.height >= pad ? menu.y - r.height : Math.max(pad, window.innerHeight - r.height - pad);
|
||||
}
|
||||
setPos({ x, y });
|
||||
}, [menu, onSendTo]);
|
||||
|
||||
if (!menu) return null;
|
||||
const n = menu.ids.length;
|
||||
// Keep the menu on-screen near the cursor.
|
||||
const x = Math.min(menu.x, window.innerWidth - 248);
|
||||
const y = Math.min(menu.y, window.innerHeight - (onSendTo ? 230 : 110));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[200] min-w-[240px] rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
||||
style={{ left: x, top: y }}
|
||||
ref={boxRef}
|
||||
className="fixed z-[200] min-w-[240px] max-h-[85vh] overflow-y-auto rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
||||
style={{ left: pos.x, top: pos.y }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
||||
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL } from '../../wailsjs/go/main/App';
|
||||
import { rstOptions, type RSTLists } from '@/lib/rst';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||
@@ -466,7 +466,22 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
{/* Top: Callsign + RST + Fetch */}
|
||||
<div className="flex items-end gap-2 mb-3">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Label>{t('qedit.callsign')}</Label>
|
||||
<div className="flex items-center">
|
||||
<Label>{t('qedit.callsign')}</Label>
|
||||
{(draft.callsign ?? '').trim() && (
|
||||
<button
|
||||
type="button"
|
||||
title={t('qrz.openTitle', { call: (draft.callsign ?? '').trim().toUpperCase() })}
|
||||
onClick={() => {
|
||||
const c = (draft.callsign ?? '').trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
|
||||
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e: any) => setLocalErr(String(e?.message ?? e)));
|
||||
}}
|
||||
className="ml-auto shrink-0 inline-flex items-center gap-0.5 text-[9px] font-semibold normal-case tracking-wider text-primary hover:underline"
|
||||
>
|
||||
QRZ ↗
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
|
||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||
import { qslStatusCellClass } from '@/lib/qslStatus';
|
||||
import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||
import type { QSOForm } from '@/types';
|
||||
@@ -54,6 +56,10 @@ type Props = {
|
||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||
onRowClicked?: (q: QSOForm) => void;
|
||||
onRowSelected?: (ids: number[]) => void;
|
||||
// The full first selected row — the Stats (F1) panel shows its entity when the
|
||||
// entry form is empty, so browsing the log answers "what have I got with this
|
||||
// one?" without retyping the call.
|
||||
onRowSelectedQso?: (row: QSOForm | null) => void;
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
@@ -85,23 +91,13 @@ function fmtMhzDots(hz?: number): string {
|
||||
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
|
||||
}
|
||||
|
||||
// Both formatters follow the display preference (Settings → General). What is
|
||||
// STORED never changes — see lib/dateFormat.
|
||||
function fmtDateUTC(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return s;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
return formatDateTimeUTC(s);
|
||||
}
|
||||
function fmtDateOnly(s: any): string {
|
||||
if (!s) return '';
|
||||
const t = String(s).trim();
|
||||
// QSL/LoTW/eQSL/ClubLog dates are ADIF YYYYMMDD; upload dates may be ISO.
|
||||
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
||||
const d = new Date(t);
|
||||
if (isNaN(d.getTime())) return t;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
return formatDateOnly(s);
|
||||
}
|
||||
|
||||
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
||||
@@ -178,8 +174,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||
|
||||
// ── QSL ──
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
|
||||
@@ -187,32 +183,32 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
||||
|
||||
// ── LoTW ──
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── eQSL ──
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
|
||||
// ── Uploads (online logbooks) ──
|
||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
||||
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
||||
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
||||
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
@@ -291,7 +287,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||
|
||||
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -303,7 +299,11 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
||||
|
||||
// Localized column catalog — rebuilt when the language changes.
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
|
||||
// Rebuild the columns when the format changes: the formatters are captured
|
||||
// inside the column definitions, so nothing else would notice.
|
||||
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||
|
||||
// Right-click: if the clicked row isn't already part of the selection,
|
||||
// select just it; then open the bulk-action menu on the whole selection.
|
||||
@@ -444,10 +444,18 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
if (widthUps.length) api.setColumnWidths(widthUps);
|
||||
}, [awardCols, awardShown]);
|
||||
|
||||
// No column filters here, deliberately. This grid holds only the most recent
|
||||
// page of QSOs, so a column filter searches THOSE rows and nothing else — and
|
||||
// an empty result then reads as "the log does not contain it" rather than "it
|
||||
// is not on this page". An operator hunting an imported CONTEST_ID across
|
||||
// 28 000 QSOs found nothing and reasonably concluded the import had dropped it.
|
||||
//
|
||||
// The advanced filter queries the whole logbook and is the honest tool for it.
|
||||
// Removing the filter also gives each header back the width of its menu icon.
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: !passOrder,
|
||||
resizable: true,
|
||||
filter: true,
|
||||
filter: false,
|
||||
suppressMovable: false,
|
||||
}), [passOrder]);
|
||||
|
||||
@@ -538,6 +546,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
setSelCount(sel.length);
|
||||
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
|
||||
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||
onRowSelectedQso?.(sel[0] ?? null);
|
||||
}
|
||||
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
||||
useEffect(() => {
|
||||
|
||||
@@ -24,6 +24,9 @@ interface Props {
|
||||
centerLat?: number | null; // operator latitude (projection centre)
|
||||
centerLon?: number | null; // operator longitude
|
||||
rotorEnabled?: boolean;
|
||||
rotors?: string[]; // logical rotor names; >1 → show a selector
|
||||
activeRotor?: number; // index of the selected rotor (0-based)
|
||||
onSelectRotor?: (i: number) => void; // switch the active rotor
|
||||
onGoto?: (az: number) => void; // click-to-turn
|
||||
onClose?: () => void;
|
||||
}
|
||||
@@ -38,7 +41,7 @@ function pt(az: number, radius: number): [number, number] {
|
||||
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
||||
}
|
||||
|
||||
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
|
||||
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose }: Props) {
|
||||
const cardinals = useMemo(
|
||||
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
||||
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
||||
@@ -100,6 +103,31 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Multiple rotors — pick which one the dial shows and turns. */}
|
||||
{rotors && rotors.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
|
||||
{rotors.map((nm, i) => {
|
||||
const active = (activeRotor ?? 0) === i;
|
||||
const label = nm?.trim() || `Rotor ${i + 1}`;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onSelectRotor?.(i)}
|
||||
className={cn(
|
||||
'flex-1 min-w-[48px] px-1.5 py-0.5 rounded text-[10px] font-semibold truncate transition-colors',
|
||||
active
|
||||
? 'bg-success text-success-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/70',
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center p-2 min-h-0">
|
||||
<svg
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -13,7 +13,7 @@ import { TunerCard } from '@/components/TunerCard';
|
||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||
import {
|
||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||
GetAmpStatuses, GetFlexState,
|
||||
@@ -26,19 +26,28 @@ type Device = { id: string; type: string; name: string; host: string; user?: str
|
||||
type Relay = { number: number; label: string; on: boolean };
|
||||
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||
|
||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 };
|
||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
|
||||
// Dashboard geometry: a grid of EQUAL columns whose cards also share a common
|
||||
// HEIGHT per row — every card stretches to the tallest one beside it. Masonry
|
||||
// packed tighter but left the row edges ragged, which read as untidy on a wide
|
||||
// screen; aligned rows are what an operator expects from a dashboard.
|
||||
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
|
||||
const GRID_GAP = 16; // px between cards, both axes
|
||||
|
||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8, dingtian: 2 };
|
||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)', dingtian: 'Dingtian IOT relay' };
|
||||
|
||||
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
|
||||
// the generic USB-serial board, whose channel count the user picks.
|
||||
const chanCount = (d: Device): number =>
|
||||
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : (RELAY_COUNT[d.type] ?? 5);
|
||||
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8)
|
||||
: d.type === 'dingtian' ? (d.channels && d.channels >= 1 ? d.channels : 2)
|
||||
: (RELAY_COUNT[d.type] ?? 5);
|
||||
|
||||
function blankDevice(): Device {
|
||||
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||
}
|
||||
|
||||
type Heading = { enabled: boolean; ok: boolean; azimuth: number };
|
||||
type Heading = { enabled: boolean; ok: boolean; azimuth: number; rotors?: string[]; active?: number };
|
||||
|
||||
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
||||
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
||||
@@ -73,6 +82,9 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
||||
centerLat={centerLat ?? null}
|
||||
centerLon={centerLon ?? null}
|
||||
rotorEnabled={hd.ok}
|
||||
rotors={hd.rotors}
|
||||
activeRotor={hd.active}
|
||||
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
||||
onGoto={(az) => turn(az)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
@@ -269,6 +281,23 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
// Max columns per row (persisted). "Auto" fills the window; a number caps the
|
||||
// row so cards wrap onto further lines even when there's horizontal room.
|
||||
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
||||
|
||||
// How many columns the dashboard shows. "Auto" fits as many CARD_MIN-wide
|
||||
// cards as the window allows; an explicit count is honoured as asked, even
|
||||
// past that width — the operator can see his own screen.
|
||||
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||
const [colCount, setColCount] = useState(1);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (cols !== 'auto') { setColCount(Number(cols) || 1); return; }
|
||||
const el = gridRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => setColCount(Math.max(1, Math.floor((el.clientWidth + GRID_GAP) / (CARD_MIN + GRID_GAP))));
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [cols]);
|
||||
// Amplifiers (Settings → Amplifier): shown here so operators without a
|
||||
// FlexRadio panel still get the controls. EVERY configured amp gets its own
|
||||
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
|
||||
@@ -415,10 +444,10 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
);
|
||||
};
|
||||
|
||||
// `wide` cards span the whole dashboard instead of sitting in one masonry
|
||||
// column. The amplifier and tuner carry meter rows and a channel selector that
|
||||
// are unreadable squeezed into a ~430px column — they are the same cards the
|
||||
// FlexRadio panel shows full-width, and they need that room here too.
|
||||
// `wide` cards take two grid columns instead of one. The amplifier and tuner
|
||||
// carry meter rows and a channel selector that are unreadable squeezed into a
|
||||
// single ~430px column — they are the same cards the FlexRadio panel shows
|
||||
// full-width, and they need that room here too.
|
||||
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
|
||||
if (rot.enabled) {
|
||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||
@@ -446,7 +475,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
|
||||
card can sit on a further line even with horizontal room to spare. */}
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
||||
{(['auto', '1', '2', '3', '4'] as const).map((c) => (
|
||||
{(['auto', '1', '2', '3', '4', '5', '6'] as const).map((c) => (
|
||||
<button key={c} type="button"
|
||||
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
||||
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||
@@ -466,31 +495,36 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Masonry dashboard: fixed-width cards flow into balanced CSS columns so
|
||||
they pack tightly by height (no ragged gaps under short cards). "Auto"
|
||||
fits as many ~430px columns as the window allows; a fixed count caps the
|
||||
container width to that many columns. Each card has a grip handle (left
|
||||
rail) as the drag initiator (the body is full of buttons). */}
|
||||
<div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance',
|
||||
...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}>
|
||||
{/* Dashboard grid: equal columns, and every card stretches to the height of
|
||||
the tallest one on its row, so the rows line up instead of ending
|
||||
ragged. One column each, two for the amplifier/tuner whose meter rows
|
||||
need the room. Each card has a grip handle (left rail) as the drag
|
||||
initiator, since the body is full of buttons. */}
|
||||
<div ref={gridRef} style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
|
||||
gap: `${GRID_GAP}px`,
|
||||
}}>
|
||||
{ordered.map((w) => (
|
||||
// column-span:all lifts a wide card out of the columns and across the
|
||||
// full container, while keeping it in document order — so drag-reorder
|
||||
// still works between wide and normal cards. Capped so it stays a card
|
||||
// and not a banner on an ultra-wide window.
|
||||
<div key={w.id} className="flex items-stretch break-inside-avoid mb-4"
|
||||
style={w.wide ? { columnSpan: 'all', maxWidth: '900px' } : undefined}
|
||||
<div key={w.id}
|
||||
// A wide card takes two columns — but never more than there are, or
|
||||
// the grid would grow a phantom column on a narrow window.
|
||||
style={{ gridColumn: w.wide ? `span ${Math.min(2, colCount)}` : undefined }}
|
||||
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
||||
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
||||
<div draggable
|
||||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragEnd={() => { dragId.current = null; }}
|
||||
title={t('station.dragMove')}
|
||||
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
|
||||
dragId.current === w.id && 'opacity-60')}>
|
||||
<GripVertical className="size-4" />
|
||||
{/* h-full down the chain is what makes the card fill the row height
|
||||
rather than sit at its natural size in a taller cell. */}
|
||||
<div className="flex items-stretch h-full">
|
||||
<div draggable
|
||||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragEnd={() => { dragId.current = null; }}
|
||||
title={t('station.dragMove')}
|
||||
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
|
||||
dragId.current === w.id && 'opacity-60')}>
|
||||
<GripVertical className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 [&>*]:h-full">{w.node}</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">{w.node}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -523,6 +557,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
onChange({ ...device, channels, labels });
|
||||
};
|
||||
const isKM = device.type === 'kmtronic';
|
||||
const isDingtian = device.type === 'dingtian';
|
||||
const isDenkovi = device.type === 'denkovi';
|
||||
const isUsbRelay = device.type === 'usbrelay';
|
||||
// COM ports for the generic USB-serial relay picker.
|
||||
@@ -573,6 +608,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
<SelectContent>
|
||||
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||||
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||||
<SelectItem value="dingtian">Dingtian IOT relay (LAN / WiFi)</SelectItem>
|
||||
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
|
||||
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -583,13 +619,13 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
{(isDenkovi || isUsbRelay) && (
|
||||
{(isDenkovi || isUsbRelay || isDingtian) && (
|
||||
<div className="space-y-1 max-w-[10rem]">
|
||||
<Label>{t('station.channels')}</Label>
|
||||
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(isDenkovi ? [4, 8] : [1, 2, 4, 8, 16]).map((n) => (
|
||||
{(isDenkovi ? [4, 8] : isDingtian ? [2, 4, 8, 16, 24, 32] : [1, 2, 4, 8, 16]).map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -632,11 +668,29 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
||||
<div className={cn('grid gap-3', (isKM || isDingtian) ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||
<div className={cn('space-y-1', (isKM || isDingtian) ? '' : 'max-w-xs')}>
|
||||
<Label>{t('station.host')}</Label>
|
||||
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||
<span className="text-[10px] text-muted-foreground">{t('station.hostHint')}</span>
|
||||
</div>
|
||||
{/* Dingtian: both are OFF on a factory board — the session ID only when
|
||||
"HTTP Session" is enabled in its web page, the password only when a
|
||||
relay password is set. */}
|
||||
{isDingtian && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.dtSession')}</Label>
|
||||
<Input className="font-mono" value={device.user ?? ''} placeholder={t('station.optional')}
|
||||
onChange={(e) => onChange({ ...device, user: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.dtPwd')}</Label>
|
||||
<Input className="font-mono" value={device.pass ?? ''} placeholder="0"
|
||||
onChange={(e) => onChange({ ...device, pass: e.target.value.replace(/[^0-9]/g, '') })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isKM && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -76,13 +76,16 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
|
||||
const peakHold = (key: string, val: number) => {
|
||||
const now = Date.now();
|
||||
const p = peak.current[key];
|
||||
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
|
||||
const connected = !!status.connected;
|
||||
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||
const rawFwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0;
|
||||
// Prefer the device's own peak detector over the sampled reading; the JS hold
|
||||
// below still smooths what is left between polls.
|
||||
const rawPeak = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
|
||||
const rawFwdW = rawPeak >= 1 ? rawPeak : 0;
|
||||
const fwdW = peakHold('fwd', rawFwdW);
|
||||
|
||||
// SWR needs RF to mean anything: sampled on receive the device reports a
|
||||
@@ -93,13 +96,14 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
|
||||
// That expiry is the point: the first version of this held the last TX value
|
||||
// with no timeout at all, so the meter sat frozen on the previous transmission
|
||||
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
|
||||
// Same 2 s window as the power peak above, so the two meters clear together.
|
||||
// Same one-second window as the power peak above, so the two meters clear
|
||||
// together.
|
||||
const swrHold = useRef<{ v: number; t: number } | null>(null);
|
||||
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
|
||||
const heldSwr = swrHold.current;
|
||||
const vswr = rawFwdW >= 1
|
||||
? rawVswr
|
||||
: heldSwr && Date.now() - heldSwr.t < 2000
|
||||
: heldSwr && Date.now() - heldSwr.t < 1000
|
||||
? heldSwr.v
|
||||
: undefined;
|
||||
const active = status.active ?? 1;
|
||||
|
||||
@@ -8,7 +8,7 @@ export type TGChannel = {
|
||||
};
|
||||
export type TGStatus = {
|
||||
connected: boolean; host?: string; last_error?: string;
|
||||
fwd_dbm?: number; fwd_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
|
||||
fwd_dbm?: number; fwd_w?: number; peak_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
|
||||
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
|
||||
antenna?: number; three_way?: boolean; message?: string;
|
||||
a?: TGChannel; b?: TGChannel;
|
||||
@@ -69,6 +69,10 @@ export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActiva
|
||||
const { t } = useI18n();
|
||||
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||
const active = status.active ?? 1;
|
||||
// The device's own peak, not the instantaneous reading: a 400 ms poll lands
|
||||
// between syllables as often as on a peak, so the plain figure showed a few
|
||||
// hundred watts on a kilowatt transmission.
|
||||
const fwdW = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||
@@ -103,7 +107,7 @@ export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActiva
|
||||
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
|
||||
<div className="text-lg font-bold font-mono leading-tight text-foreground/80">
|
||||
{status.fwd_w && status.fwd_w >= 1 ? `${Math.round(status.fwd_w)} W` : '—'}
|
||||
{fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
wpm: number;
|
||||
macros: WKMacro[];
|
||||
sent: string; // text echoed back by the keyer as it transmits
|
||||
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu'; // CW output engine (chosen in Settings → CW Keyer)
|
||||
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood'; // CW output engine (chosen in Settings → CW Keyer)
|
||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||
onSetBreakIn?: (mode: number) => void;
|
||||
onSelectPort: (p: string) => void;
|
||||
@@ -101,16 +101,16 @@ export function WinkeyerPanel({
|
||||
<Radio className="size-4 text-primary shrink-0" />
|
||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : 'WinKeyer'}
|
||||
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : source === 'kenwood' ? 'Kenwood CW' : 'WinKeyer'}
|
||||
</span>
|
||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||
<div className="flex-1" />
|
||||
{source === 'icom' || source === 'flex' || source === 'yaesu' ? (
|
||||
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' ? (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
{source === 'flex'
|
||||
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||
: source === 'yaesu'
|
||||
: source === 'yaesu' || source === 'kenwood'
|
||||
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
||||
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||
</span>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||||
import { getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -63,7 +64,9 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
|
||||
// Localized column catalog (shared with the Recent QSOs grid).
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
|
||||
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
|
||||
@@ -37,7 +37,11 @@ const TabsContent = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', className)}
|
||||
// No focus ring on the panel itself. Radix makes the content focusable and
|
||||
// moves focus into it when a tab is selected, so clicking anywhere in the
|
||||
// lower half drew a full-width orange rule under the tab strip. The panel is
|
||||
// a container — what it holds carries its own focus styling.
|
||||
className={cn('mt-2 focus-visible:outline-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// How dates are DISPLAYED. Nothing here touches what is stored.
|
||||
//
|
||||
// The log keeps ADIF/ISO throughout — the database, ADIF export, LoTW, every
|
||||
// upload. This is the reading layer only, because an operator reading
|
||||
// "2026-07-30" as the 7th of the 30th month is reading their own log wrongly,
|
||||
// and that is a display problem, not a data one.
|
||||
//
|
||||
// Storage stays ISO deliberately and permanently: it sorts correctly as text,
|
||||
// it is what ADIF specifies, and a log whose stored format followed a UI
|
||||
// preference would become unreadable the day the preference changed.
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
export type DateFormat = 'iso' | 'fr' | 'us';
|
||||
|
||||
const KEY = 'opslog.dateFormat';
|
||||
|
||||
function read(): DateFormat {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return v === 'fr' || v === 'us' ? v : 'iso';
|
||||
}
|
||||
|
||||
let current: DateFormat = read();
|
||||
|
||||
// A minimal store so every grid and panel re-renders the moment the format
|
||||
// changes, rather than showing the old one until the next restart.
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getDateFormat(): DateFormat {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function setDateFormat(f: DateFormat): void {
|
||||
current = f;
|
||||
writeUiPref(KEY, f);
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function subscribeDateFormat(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
// reloadDateFormat re-reads the stored value — for after the portable prefs
|
||||
// have been synced from the database at startup, which happens AFTER this
|
||||
// module's first read.
|
||||
export function reloadDateFormat(): void {
|
||||
const v = read();
|
||||
if (v !== current) {
|
||||
current = v;
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
// order arranges the three parts. Separators stay '-' in every format: a slash
|
||||
// in the French one would be conventional but it also invites the reader to
|
||||
// take it for a US date, which is exactly the confusion being fixed.
|
||||
function order(y: number, m: number, d: number): string {
|
||||
switch (current) {
|
||||
case 'fr':
|
||||
return `${pad(d)}-${pad(m)}-${y}`;
|
||||
case 'us':
|
||||
return `${pad(m)}-${pad(d)}-${y}`;
|
||||
}
|
||||
return `${y}-${pad(m)}-${pad(d)}`;
|
||||
}
|
||||
|
||||
// formatDateTimeUTC renders a timestamp with the time, in UTC.
|
||||
//
|
||||
// UTC is not negotiable and is not part of the preference: a logbook is kept in
|
||||
// UTC, and a date shown in local time would disagree with the QSO's own record
|
||||
// twice a year.
|
||||
export function formatDateTimeUTC(s: unknown): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(String(s));
|
||||
if (isNaN(d.getTime())) return String(s);
|
||||
return `${order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
// formatDateOnly renders a date with no time. It accepts both the ADIF form
|
||||
// (YYYYMMDD, used by every QSL and upload date) and anything Date can parse.
|
||||
export function formatDateOnly(s: unknown): string {
|
||||
if (!s) return '';
|
||||
const t = String(s).trim();
|
||||
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||
if (m) return order(Number(m[1]), Number(m[2]), Number(m[3]));
|
||||
const d = new Date(t);
|
||||
if (isNaN(d.getTime())) return t;
|
||||
return order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Grey line — the day/night terminator, and the twilight band around it where
|
||||
// HF propagation briefly does things it does at no other time.
|
||||
//
|
||||
// The maths is the standard low-precision solar position (Meeus, chapter 25, as
|
||||
// used by NOAA's solar calculator): good to well under a degree, which is far
|
||||
// finer than the band being drawn. No ephemeris, no network, no dependency —
|
||||
// this has to work in a field shack with no internet.
|
||||
//
|
||||
// Everything here is pure so it can be tested against known positions; the
|
||||
// drawing lives in the map component.
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
// julianDay for a date, including the fraction of the day.
|
||||
export function julianDay(d: Date): number {
|
||||
return d.getTime() / 86400000 + 2440587.5;
|
||||
}
|
||||
|
||||
// Sun's declination and Greenwich hour angle, both in degrees.
|
||||
//
|
||||
// Returned together because they are computed from the same intermediates and
|
||||
// every caller needs both — splitting them into two exported functions would
|
||||
// double the work at each call for the sake of a tidier signature.
|
||||
export function sunPosition(date: Date): { dec: number; gha: number } {
|
||||
const jd = julianDay(date);
|
||||
const n = jd - 2451545.0; // days from J2000.0
|
||||
|
||||
// Mean longitude and mean anomaly of the Sun.
|
||||
const L = (280.460 + 0.9856474 * n) % 360;
|
||||
const g = ((357.528 + 0.9856003 * n) % 360) * DEG;
|
||||
|
||||
// Ecliptic longitude: the equation of centre applied to the mean longitude.
|
||||
const lambda = (L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * DEG;
|
||||
|
||||
// Obliquity of the ecliptic, slowly decreasing.
|
||||
const eps = (23.439 - 0.0000004 * n) * DEG;
|
||||
|
||||
const dec = Math.asin(Math.sin(eps) * Math.sin(lambda)) / DEG;
|
||||
|
||||
// Right ascension, kept in the same quadrant as the ecliptic longitude —
|
||||
// atan2 alone lands 180° out for half the year, which mirrors the whole
|
||||
// terminator about the prime meridian. Six months of a plausible-looking
|
||||
// wrong answer is exactly the kind of bug that survives review.
|
||||
let ra = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)) / DEG;
|
||||
if (ra < 0) ra += 360;
|
||||
|
||||
// Greenwich mean sidereal time, in degrees.
|
||||
const gmst = (280.46061837 + 360.98564736629 * n) % 360;
|
||||
|
||||
let gha = gmst - ra;
|
||||
gha = ((gha % 360) + 360) % 360;
|
||||
return { dec, gha };
|
||||
}
|
||||
|
||||
// terminatorLatitude returns the latitude, at a given longitude, where the Sun
|
||||
// sits at `altitude` degrees above (or below) the horizon.
|
||||
//
|
||||
// altitude 0 is the geometric terminator; -6 is civil twilight, the outer edge
|
||||
// of the grey line as operators use the term.
|
||||
//
|
||||
// Returns null where no such latitude exists at that longitude — inside the
|
||||
// polar day or polar night, where the Sun never crosses that altitude at all.
|
||||
export function terminatorLatitude(lonDeg: number, dec: number, gha: number, altitude = 0): number | null {
|
||||
const ha = (gha + lonDeg) * DEG; // local hour angle
|
||||
const d = dec * DEG;
|
||||
const h = altitude * DEG;
|
||||
|
||||
// From sin(alt) = sin(dec)sin(lat) + cos(dec)cos(lat)cos(ha), solved for lat:
|
||||
// A·sin(lat) + B·cos(lat) = sin(alt), A = sin(dec), B = cos(dec)cos(ha)
|
||||
//
|
||||
// That has TWO solutions per meridian, and picking the wrong one is not a
|
||||
// small error — it mirrors the answer about the equator. New York at 02:00
|
||||
// local in December came out as daylight because the branch chosen put the
|
||||
// terminator at +63° where it belongs at −63°: a shaded map that looks
|
||||
// entirely plausible and is exactly wrong.
|
||||
//
|
||||
// So anchor on the geometric terminator, where the answer is unambiguous and
|
||||
// classical, and take whichever branch is nearest it. At altitude 0 that
|
||||
// reproduces it exactly; for the twilight band it follows it by continuity,
|
||||
// which is the physical requirement — the twilight curve is a small offset
|
||||
// from the terminator, never on the other side of the planet.
|
||||
const A = Math.sin(d);
|
||||
const B = Math.cos(d) * Math.cos(ha);
|
||||
const R = Math.hypot(A, B);
|
||||
if (R === 0) return null;
|
||||
const s = Math.sin(h) / R;
|
||||
if (s < -1 || s > 1) return null; // the Sun never reaches this altitude here
|
||||
|
||||
// The classical terminator. Division by zero is deliberate and correct here:
|
||||
// at an equinox tan(dec) → 0 and the terminator becomes a meridian, which is
|
||||
// exactly what atan(±Infinity) = ±90° expresses.
|
||||
const t = Math.tan(d);
|
||||
if (t === 0 && Math.cos(ha) === 0) return null; // degenerate: no crossing to name
|
||||
const lat0 = Math.atan(-Math.cos(ha) / t) / DEG;
|
||||
|
||||
const phi = Math.atan2(B, A); // phase of the A·sin(lat) + B·cos(lat) sum
|
||||
const fold = (x: number) => {
|
||||
let v = ((x + 180) % 360 + 360) % 360 - 180;
|
||||
if (v > 90) v = 180 - v;
|
||||
if (v < -90) v = -180 - v;
|
||||
return v;
|
||||
};
|
||||
const c1 = fold((Math.asin(s) - phi) / DEG);
|
||||
const c2 = fold((Math.PI - Math.asin(s) - phi) / DEG);
|
||||
return Math.abs(c1 - lat0) <= Math.abs(c2 - lat0) ? c1 : c2;
|
||||
}
|
||||
|
||||
// nightPolygon returns a ring covering the part of the world where the Sun is
|
||||
// below `altitude`, ready for L.polygon (as [lat, lon] pairs).
|
||||
//
|
||||
// The ring runs along the terminator from west to east and closes along
|
||||
// whichever pole is in darkness — which pole that is flips with the season, and
|
||||
// getting it wrong shades the lit half of the planet.
|
||||
export function nightPolygon(date: Date, altitude = 0, stepDeg = 2): [number, number][] {
|
||||
const { dec, gha } = sunPosition(date);
|
||||
const ring: [number, number][] = [];
|
||||
|
||||
for (let lon = -180; lon <= 180; lon += stepDeg) {
|
||||
const lat = terminatorLatitude(lon, dec, gha, altitude);
|
||||
// Inside a polar day/night there is no crossing; clamp to the pole so the
|
||||
// ring stays closed rather than tearing open across the map.
|
||||
ring.push([lat === null ? (dec > 0 ? -90 : 90) : lat, lon]);
|
||||
}
|
||||
|
||||
// Northern summer (positive declination) → the SOUTH pole is dark.
|
||||
const pole = dec > 0 ? -90 : 90;
|
||||
ring.push([pole, 180]);
|
||||
ring.push([pole, -180]);
|
||||
return ring;
|
||||
}
|
||||
+60
-42
File diff suppressed because one or more lines are too long
@@ -0,0 +1,41 @@
|
||||
// Colouring for QSL / confirmation status letters.
|
||||
//
|
||||
// These columns are read by COLOUR before they are read as letters: an operator
|
||||
// scanning a hundred rows wants to see where the gaps are, not to read "Y" a
|
||||
// hundred times.
|
||||
//
|
||||
// Colour ONLY — no pill, no badge. These values sit among callsigns and dates,
|
||||
// and a column of coloured boxes would shout louder than the callsign it
|
||||
// belongs to.
|
||||
//
|
||||
// The classes are semantic tokens, not fixed colours, so all four themes follow
|
||||
// and the contrast stays right on the light ones.
|
||||
|
||||
// ADIF confirmation values, and what they mean to the operator:
|
||||
// Y confirmed / sent
|
||||
// N not sent, not received
|
||||
// R requested — queued, waiting on the other station or the service
|
||||
// I ignore (ADIF's "invalid/ignore"), left in default ink: it is neither
|
||||
// good news nor bad, and colouring it would put it in one camp or the
|
||||
// other.
|
||||
//
|
||||
// Anything else — a blank, or a status some other logger wrote — is left alone
|
||||
// rather than guessed at.
|
||||
export function qslStatusClass(value: unknown): string {
|
||||
switch (String(value ?? '').trim().toUpperCase()) {
|
||||
case 'Y':
|
||||
return 'text-success';
|
||||
case 'N':
|
||||
return 'text-destructive';
|
||||
case 'R':
|
||||
return 'text-info';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// qslStatusCellClass is the AG-Grid form: same rule, plus the monospace the
|
||||
// status columns already used so the letters stay in a straight column.
|
||||
export function qslStatusCellClass(p: { value?: unknown }): string {
|
||||
const c = qslStatusClass(p?.value);
|
||||
return c ? 'font-mono ' + c : 'font-mono';
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Sunrise / sunset for a station, in UTC.
|
||||
//
|
||||
// Built on greyline.ts's sunPosition() rather than on a second implementation of
|
||||
// the solar equations: the map's terminator and the entry strip's sunrise must
|
||||
// never disagree, and there is a whole comment block over there about a sign
|
||||
// error that produced six months of plausible-looking wrong answers. One source
|
||||
// of astronomy, one place to be wrong.
|
||||
//
|
||||
// Everything is UTC. Amateur radio runs on UTC, the log is in UTC, and a local
|
||||
// time would raise the question "local to whom — me or the DX?".
|
||||
import { sunPosition } from './greyline';
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
// Standard altitude of the Sun's centre at rise/set: half a solar diameter below
|
||||
// the horizon plus atmospheric refraction.
|
||||
const H0 = -0.833;
|
||||
|
||||
// solarAltitude returns the Sun's elevation in degrees at a place and instant.
|
||||
function solarAltitude(date: Date, latDeg: number, lonDeg: number): number {
|
||||
const { dec, gha } = sunPosition(date);
|
||||
const ha = (gha + lonDeg) * DEG; // local hour angle
|
||||
const d = dec * DEG;
|
||||
const lat = latDeg * DEG;
|
||||
return Math.asin(Math.sin(lat) * Math.sin(d) + Math.cos(lat) * Math.cos(d) * Math.cos(ha)) / DEG;
|
||||
}
|
||||
|
||||
export type SunTimes = {
|
||||
rise: string; // "HH:MM" UTC, "" when the Sun does not rise that day
|
||||
set: string;
|
||||
polarDay: boolean; // Sun stays up all day
|
||||
polarNight: boolean; // Sun never comes up
|
||||
};
|
||||
|
||||
// sunTimes scans the UTC day a minute at a time and reports where the Sun
|
||||
// crosses the rise/set altitude.
|
||||
//
|
||||
// A scan, not the closed-form hour-angle formula, because the closed form needs
|
||||
// special-casing for the polar cases and for days where the declination shifts
|
||||
// enough to matter, and it silently returns NaN rather than saying "no sunrise
|
||||
// here today". 1440 evaluations is nothing, and the failure mode is honest: no
|
||||
// crossing found means no sunrise, which is a real answer above the Arctic
|
||||
// circle. The crossing minute is then refined by linear interpolation, so the
|
||||
// result is accurate to well under a minute despite the coarse scan.
|
||||
export function sunTimes(date: Date, latDeg: number, lonDeg: number): SunTimes {
|
||||
const out: SunTimes = { rise: '', set: '', polarDay: false, polarNight: false };
|
||||
if (!isFinite(latDeg) || !isFinite(lonDeg)) return out;
|
||||
|
||||
const start = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||
const minute = 60000;
|
||||
let prev = solarAltitude(new Date(start), latDeg, lonDeg);
|
||||
const anyUp = prev > H0;
|
||||
let anyDown = prev <= H0;
|
||||
let sawUp = anyUp;
|
||||
|
||||
for (let i = 1; i <= 1440; i++) {
|
||||
const alt = solarAltitude(new Date(start + i * minute), latDeg, lonDeg);
|
||||
if (alt > H0) sawUp = true; else anyDown = true;
|
||||
if (prev <= H0 && alt > H0 && !out.rise) {
|
||||
out.rise = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
||||
} else if (prev > H0 && alt <= H0 && !out.set) {
|
||||
out.set = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
||||
}
|
||||
prev = alt;
|
||||
}
|
||||
if (!out.rise && !out.set) {
|
||||
out.polarDay = sawUp && !anyDown;
|
||||
out.polarNight = !sawUp;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// frac is where between two samples the altitude crosses H0.
|
||||
function frac(a: number, b: number): number {
|
||||
const d = b - a;
|
||||
return d === 0 ? 0 : (H0 - a) / d;
|
||||
}
|
||||
|
||||
function fmt(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
// folder (data/) to another machine. These helpers mirror them into the DB
|
||||
// settings table (ui.* keys, like the grid columns) so the whole setup is
|
||||
// identical after a copy.
|
||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||
import { GetUIPref, SetUIPref, LogUIError } from '../../wailsjs/go/main/App';
|
||||
|
||||
// Keys that must travel with data/ (DB is the portable source of truth; the
|
||||
// localStorage copy is just a fast, synchronous cache).
|
||||
@@ -17,6 +17,8 @@ const PORTABLE_KEYS = [
|
||||
'opslog.autofocusWB', // auto-focus Worked-before
|
||||
'hamlog.filterPresets', // Filter Builder saved presets
|
||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||
'opslog.showAmpWidget', // amplifier widget shown next to the keyers
|
||||
'opslog.ampSel.widget', // which amplifier that widget shows ("all" or an amp id)
|
||||
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
||||
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
|
||||
@@ -28,11 +30,15 @@ const PORTABLE_KEYS = [
|
||||
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||
'opslog.dateFormat', // how dates are DISPLAYED (iso / fr / us); storage stays ISO
|
||||
'opslog.mapGreyline', // world map: grey line (day/night terminator) shown
|
||||
'opslog.awardRefSort', 'opslog.awardRefSortDir', // award reference table: sort column and direction
|
||||
// Cluster filter selections — restored on reopen.
|
||||
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||
'opslog.activeTab', // last selected tab
|
||||
'opslog.mainSplit', // Main tab: width share of the left pane (percent)
|
||||
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||
@@ -62,5 +68,11 @@ export async function syncPortablePrefs(): Promise<void> {
|
||||
// Use it everywhere these keys are written instead of localStorage.setItem.
|
||||
export function writeUiPref(key: string, value: string): void {
|
||||
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
|
||||
SetUIPref(key, value).catch(() => { /* DB unavailable — the cache still holds it */ });
|
||||
SetUIPref(key, value).catch((e: any) => {
|
||||
// The cache still holds it, so the interface behaves — but the BACKEND
|
||||
// reads some of these keys too (digital-mode grouping decides how the
|
||||
// cluster judges a slot). A write that reaches localStorage and not the
|
||||
// database makes the two disagree, and used to do so in silence.
|
||||
try { LogUIError("ui pref", "could not store " + key + ": " + String(e?.message ?? e), ""); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
+17
-5
@@ -5,6 +5,8 @@ import App from './App'
|
||||
import { syncPortablePrefs } from './lib/uiPref'
|
||||
import { I18nProvider } from './lib/i18n'
|
||||
import { ThemeProvider, initTheme } from './lib/theme'
|
||||
import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary'
|
||||
import { reloadDateFormat } from './lib/dateFormat'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
@@ -17,13 +19,23 @@ syncPortablePrefs().finally(() => {
|
||||
// Stamp the persisted theme onto <html> before render so the correct
|
||||
// palette is applied immediately (portable pref hydrated just above).
|
||||
initTheme()
|
||||
// The date format module read localStorage at import time, which is BEFORE
|
||||
// the portable prefs were pulled from the database. Re-read it now, or a
|
||||
// copied data/ folder would show ISO until the next launch.
|
||||
reloadDateFormat()
|
||||
// Catch what a boundary cannot: errors thrown from timers, event handlers and
|
||||
// rejected promises. Installed before the first render so a fault during
|
||||
// startup is reported too.
|
||||
installGlobalErrorLogging()
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<App/>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
<ErrorBoundary>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<App/>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -679,10 +679,10 @@
|
||||
disappears on the dark themes. Invert it there — the control itself is worth
|
||||
keeping (OS calendar, locale date order, keyboard entry), only its icon needs
|
||||
help. */
|
||||
[data-theme='dim-slate'] input[type='date']::-webkit-calendar-picker-indicator,
|
||||
[data-theme='dark-warm'] input[type='date']::-webkit-calendar-picker-indicator,
|
||||
[data-theme='dark-graphite'] input[type='date']::-webkit-calendar-picker-indicator,
|
||||
[data-theme='high-contrast'] input[type='date']::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1) brightness(1.6);
|
||||
opacity: 0.75;
|
||||
Every temporal input carries the same glyph, so the rule names them all:
|
||||
datetime-local was left out at first and its icon stayed invisible. */
|
||||
:is([data-theme='dim-slate'], [data-theme='dark-warm'], [data-theme='dark-graphite'], [data-theme='high-contrast'])
|
||||
:is(input[type='date'], input[type='datetime-local'], input[type='time'], input[type='month'], input[type='week'])::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1) brightness(1.8);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.22.1';
|
||||
export const APP_VERSION = '0.23.8';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+56
-6
@@ -61,6 +61,8 @@ export function ApplyAwardUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||
|
||||
export function AudioApplyLevels(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function AudioMonitorActive():Promise<boolean>;
|
||||
|
||||
export function AudioStartMonitor():Promise<void>;
|
||||
@@ -73,7 +75,7 @@ export function AudioStopTX():Promise<void>;
|
||||
|
||||
export function AudioTXActive():Promise<boolean>;
|
||||
|
||||
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
||||
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function AwardFields():Promise<Array<string>>;
|
||||
|
||||
@@ -85,14 +87,20 @@ export function AwardsFolder():Promise<string>;
|
||||
|
||||
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
|
||||
|
||||
export function BandSlotQSOs(arg1:string,arg2:number,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function BrowseExecutable():Promise<string>;
|
||||
|
||||
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
||||
|
||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||
|
||||
export function CIVTraceEnabled():Promise<boolean>;
|
||||
|
||||
export function CWDecoderRunning():Promise<boolean>;
|
||||
|
||||
export function CancelConfirmations():Promise<void>;
|
||||
|
||||
export function ChatAvailable():Promise<boolean>;
|
||||
|
||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||
@@ -215,6 +223,8 @@ export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexApplyBandPower(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function FlexBackspaceCW(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexMox(arg1:boolean):Promise<void>;
|
||||
@@ -359,7 +369,7 @@ export function GetAudioSettings():Promise<main.AudioSettings>;
|
||||
|
||||
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
||||
|
||||
export function GetAward(arg1:string):Promise<award.Result>;
|
||||
export function GetAward(arg1:string,arg2:string):Promise<award.Result>;
|
||||
|
||||
export function GetAwardDefs():Promise<Array<award.Def>>;
|
||||
|
||||
@@ -417,6 +427,8 @@ export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||
|
||||
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
|
||||
|
||||
export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
|
||||
|
||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||
|
||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||
@@ -461,7 +473,7 @@ export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
||||
|
||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||
|
||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
||||
|
||||
export function GetSPEStatus():Promise<spe.Status>;
|
||||
|
||||
@@ -483,6 +495,8 @@ export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
||||
|
||||
export function GetTelemetryEnabled():Promise<boolean>;
|
||||
|
||||
export function GetTrackedAwards():Promise<Array<string>>;
|
||||
|
||||
export function GetTunerGeniusSettings():Promise<main.TunerGeniusSettings>;
|
||||
|
||||
export function GetTunerGeniusStatus():Promise<tunergenius.Status>;
|
||||
@@ -589,7 +603,7 @@ export function IcomStopCW():Promise<void>;
|
||||
|
||||
export function IcomTune():Promise<void>;
|
||||
|
||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean,arg5:Record<string, string>):Promise<adif.ImportResult>;
|
||||
|
||||
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
@@ -597,6 +611,12 @@ export function ImportAwards():Promise<main.AwardImportResult>;
|
||||
|
||||
export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
||||
|
||||
export function IsNewUSCounty(arg1:string,arg2:string):Promise<boolean>;
|
||||
|
||||
export function KenwoodSendCW(arg1:string):Promise<void>;
|
||||
|
||||
export function KenwoodStopCW():Promise<void>;
|
||||
|
||||
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||
|
||||
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||
@@ -637,6 +657,8 @@ export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||
|
||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||
|
||||
export function LogUIError(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||
|
||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||
|
||||
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
|
||||
@@ -695,6 +717,10 @@ export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||
|
||||
export function PGXLSetOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function PTTHotkeyDown():Promise<void>;
|
||||
|
||||
export function PTTHotkeyUp():Promise<void>;
|
||||
|
||||
export function PickADIFMonitorFile():Promise<string>;
|
||||
|
||||
export function PickAudioFolder():Promise<string>;
|
||||
@@ -745,10 +771,20 @@ export function QSOAudioBegin():Promise<boolean>;
|
||||
|
||||
export function QSOAudioCancel():Promise<void>;
|
||||
|
||||
export function QSOAudioManualReady():Promise<boolean>;
|
||||
|
||||
export function QSOAudioManualStart():Promise<boolean>;
|
||||
|
||||
export function QSOAudioPlayOnAir():Promise<void>;
|
||||
|
||||
export function QSOAudioResetClock():Promise<boolean>;
|
||||
|
||||
export function QSOAudioRestart():Promise<boolean>;
|
||||
|
||||
export function QSOAudioResume():Promise<boolean>;
|
||||
|
||||
export function QSOAudioStop():Promise<boolean>;
|
||||
|
||||
export function QuitApp():Promise<void>;
|
||||
|
||||
export function RecomputeAllAwardRefs():Promise<number>;
|
||||
@@ -835,6 +871,8 @@ export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>
|
||||
|
||||
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
|
||||
|
||||
export function SaveFlexBandPower(arg1:Record<string, main.FlexBandPower>):Promise<void>;
|
||||
|
||||
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||
|
||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||
@@ -855,12 +893,14 @@ export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||
|
||||
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||
|
||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
|
||||
|
||||
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||
|
||||
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||
|
||||
export function SaveTrackedAwards(arg1:Array<string>):Promise<void>;
|
||||
|
||||
export function SaveTunerGeniusSettings(arg1:main.TunerGeniusSettings):Promise<void>;
|
||||
|
||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||
@@ -885,12 +925,16 @@ export function SendLogToDeveloper():Promise<void>;
|
||||
|
||||
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||
|
||||
export function SetActiveRotor(arg1:number):Promise<void>;
|
||||
|
||||
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
||||
|
||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||
|
||||
export function SetCATMode(arg1:string):Promise<void>;
|
||||
|
||||
export function SetCIVTrace(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||
|
||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||
@@ -903,6 +947,8 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||
|
||||
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function SetPassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||
@@ -963,6 +1009,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||
|
||||
export function TailLogFile(arg1:number):Promise<string>;
|
||||
|
||||
export function TestCloudlogUpload():Promise<string>;
|
||||
|
||||
export function TestClublogUpload():Promise<string>;
|
||||
@@ -983,7 +1031,7 @@ export function TestPTT(arg1:main.AudioSettings):Promise<void>;
|
||||
|
||||
export function TestQRZUpload():Promise<string>;
|
||||
|
||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
|
||||
|
||||
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
||||
|
||||
@@ -1033,6 +1081,8 @@ export function WinkeyerSetSpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function WinkeyerStop():Promise<void>;
|
||||
|
||||
export function WinkeyerTraceEnabled():Promise<boolean>;
|
||||
|
||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||
|
||||
export function YaesuSendCW(arg1:string):Promise<void>;
|
||||
|
||||
+112
-12
@@ -70,6 +70,10 @@ export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function AudioApplyLevels(arg1, arg2) {
|
||||
return window['go']['main']['App']['AudioApplyLevels'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function AudioMonitorActive() {
|
||||
return window['go']['main']['App']['AudioMonitorActive']();
|
||||
}
|
||||
@@ -94,8 +98,8 @@ export function AudioTXActive() {
|
||||
return window['go']['main']['App']['AudioTXActive']();
|
||||
}
|
||||
|
||||
export function AwardCellQSOs(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
||||
export function AwardCellQSOs(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function AwardFields() {
|
||||
@@ -118,6 +122,10 @@ export function BackfillUSCounties() {
|
||||
return window['go']['main']['App']['BackfillUSCounties']();
|
||||
}
|
||||
|
||||
export function BandSlotQSOs(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['BandSlotQSOs'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function BrowseExecutable() {
|
||||
return window['go']['main']['App']['BrowseExecutable']();
|
||||
}
|
||||
@@ -130,10 +138,18 @@ export function BulkUpdateQSL(arg1, arg2) {
|
||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function CIVTraceEnabled() {
|
||||
return window['go']['main']['App']['CIVTraceEnabled']();
|
||||
}
|
||||
|
||||
export function CWDecoderRunning() {
|
||||
return window['go']['main']['App']['CWDecoderRunning']();
|
||||
}
|
||||
|
||||
export function CancelConfirmations() {
|
||||
return window['go']['main']['App']['CancelConfirmations']();
|
||||
}
|
||||
|
||||
export function ChatAvailable() {
|
||||
return window['go']['main']['App']['ChatAvailable']();
|
||||
}
|
||||
@@ -378,6 +394,10 @@ export function FlexApplyBandAntenna(arg1) {
|
||||
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
|
||||
}
|
||||
|
||||
export function FlexApplyBandPower(arg1, arg2) {
|
||||
return window['go']['main']['App']['FlexApplyBandPower'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function FlexBackspaceCW(arg1) {
|
||||
return window['go']['main']['App']['FlexBackspaceCW'](arg1);
|
||||
}
|
||||
@@ -666,8 +686,8 @@ export function GetAutostartPrograms() {
|
||||
return window['go']['main']['App']['GetAutostartPrograms']();
|
||||
}
|
||||
|
||||
export function GetAward(arg1) {
|
||||
return window['go']['main']['App']['GetAward'](arg1);
|
||||
export function GetAward(arg1, arg2) {
|
||||
return window['go']['main']['App']['GetAward'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetAwardDefs() {
|
||||
@@ -782,6 +802,10 @@ export function GetFlexBandAntennas() {
|
||||
return window['go']['main']['App']['GetFlexBandAntennas']();
|
||||
}
|
||||
|
||||
export function GetFlexBandPower() {
|
||||
return window['go']['main']['App']['GetFlexBandPower']();
|
||||
}
|
||||
|
||||
export function GetFlexState() {
|
||||
return window['go']['main']['App']['GetFlexState']();
|
||||
}
|
||||
@@ -870,8 +894,8 @@ export function GetRotatorHeading() {
|
||||
return window['go']['main']['App']['GetRotatorHeading']();
|
||||
}
|
||||
|
||||
export function GetRotatorSettings() {
|
||||
return window['go']['main']['App']['GetRotatorSettings']();
|
||||
export function GetRotators() {
|
||||
return window['go']['main']['App']['GetRotators']();
|
||||
}
|
||||
|
||||
export function GetSPEStatus() {
|
||||
@@ -914,6 +938,10 @@ export function GetTelemetryEnabled() {
|
||||
return window['go']['main']['App']['GetTelemetryEnabled']();
|
||||
}
|
||||
|
||||
export function GetTrackedAwards() {
|
||||
return window['go']['main']['App']['GetTrackedAwards']();
|
||||
}
|
||||
|
||||
export function GetTunerGeniusSettings() {
|
||||
return window['go']['main']['App']['GetTunerGeniusSettings']();
|
||||
}
|
||||
@@ -1126,8 +1154,8 @@ export function IcomTune() {
|
||||
return window['go']['main']['App']['IcomTune']();
|
||||
}
|
||||
|
||||
export function ImportADIF(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
|
||||
export function ImportADIF(arg1, arg2, arg3, arg4, arg5) {
|
||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4, arg5);
|
||||
}
|
||||
|
||||
export function ImportAwardReferencesText(arg1, arg2) {
|
||||
@@ -1142,6 +1170,18 @@ export function InspectAwardImport() {
|
||||
return window['go']['main']['App']['InspectAwardImport']();
|
||||
}
|
||||
|
||||
export function IsNewUSCounty(arg1, arg2) {
|
||||
return window['go']['main']['App']['IsNewUSCounty'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function KenwoodSendCW(arg1) {
|
||||
return window['go']['main']['App']['KenwoodSendCW'](arg1);
|
||||
}
|
||||
|
||||
export function KenwoodStopCW() {
|
||||
return window['go']['main']['App']['KenwoodStopCW']();
|
||||
}
|
||||
|
||||
export function LaunchAutostartProgram(arg1) {
|
||||
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
||||
}
|
||||
@@ -1222,6 +1262,10 @@ export function LogUDPLoggedADIF(arg1) {
|
||||
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
||||
}
|
||||
|
||||
export function LogUIError(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['LogUIError'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function LookupCallsign(arg1) {
|
||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||
}
|
||||
@@ -1338,6 +1382,14 @@ export function PGXLSetOperate(arg1) {
|
||||
return window['go']['main']['App']['PGXLSetOperate'](arg1);
|
||||
}
|
||||
|
||||
export function PTTHotkeyDown() {
|
||||
return window['go']['main']['App']['PTTHotkeyDown']();
|
||||
}
|
||||
|
||||
export function PTTHotkeyUp() {
|
||||
return window['go']['main']['App']['PTTHotkeyUp']();
|
||||
}
|
||||
|
||||
export function PickADIFMonitorFile() {
|
||||
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||
}
|
||||
@@ -1438,6 +1490,18 @@ export function QSOAudioCancel() {
|
||||
return window['go']['main']['App']['QSOAudioCancel']();
|
||||
}
|
||||
|
||||
export function QSOAudioManualReady() {
|
||||
return window['go']['main']['App']['QSOAudioManualReady']();
|
||||
}
|
||||
|
||||
export function QSOAudioManualStart() {
|
||||
return window['go']['main']['App']['QSOAudioManualStart']();
|
||||
}
|
||||
|
||||
export function QSOAudioPlayOnAir() {
|
||||
return window['go']['main']['App']['QSOAudioPlayOnAir']();
|
||||
}
|
||||
|
||||
export function QSOAudioResetClock() {
|
||||
return window['go']['main']['App']['QSOAudioResetClock']();
|
||||
}
|
||||
@@ -1446,6 +1510,14 @@ export function QSOAudioRestart() {
|
||||
return window['go']['main']['App']['QSOAudioRestart']();
|
||||
}
|
||||
|
||||
export function QSOAudioResume() {
|
||||
return window['go']['main']['App']['QSOAudioResume']();
|
||||
}
|
||||
|
||||
export function QSOAudioStop() {
|
||||
return window['go']['main']['App']['QSOAudioStop']();
|
||||
}
|
||||
|
||||
export function QuitApp() {
|
||||
return window['go']['main']['App']['QuitApp']();
|
||||
}
|
||||
@@ -1618,6 +1690,10 @@ export function SaveFlexBandAntennas(arg1) {
|
||||
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
|
||||
}
|
||||
|
||||
export function SaveFlexBandPower(arg1) {
|
||||
return window['go']['main']['App']['SaveFlexBandPower'](arg1);
|
||||
}
|
||||
|
||||
export function SaveListsSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
||||
}
|
||||
@@ -1658,8 +1734,8 @@ export function SaveRelayAuto(arg1) {
|
||||
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRotatorSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
||||
export function SaveRotators(arg1) {
|
||||
return window['go']['main']['App']['SaveRotators'](arg1);
|
||||
}
|
||||
|
||||
export function SaveStationDevices(arg1) {
|
||||
@@ -1670,6 +1746,10 @@ export function SaveStationSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveTrackedAwards(arg1) {
|
||||
return window['go']['main']['App']['SaveTrackedAwards'](arg1);
|
||||
}
|
||||
|
||||
export function SaveTunerGeniusSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveTunerGeniusSettings'](arg1);
|
||||
}
|
||||
@@ -1718,6 +1798,10 @@ export function SendQSORecordingEmail(arg1) {
|
||||
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
||||
}
|
||||
|
||||
export function SetActiveRotor(arg1) {
|
||||
return window['go']['main']['App']['SetActiveRotor'](arg1);
|
||||
}
|
||||
|
||||
export function SetAlertEmailTo(arg1) {
|
||||
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
|
||||
}
|
||||
@@ -1730,6 +1814,10 @@ export function SetCATMode(arg1) {
|
||||
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||
}
|
||||
|
||||
export function SetCIVTrace(arg1) {
|
||||
return window['go']['main']['App']['SetCIVTrace'](arg1);
|
||||
}
|
||||
|
||||
export function SetCWDecoderPitch(arg1) {
|
||||
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
||||
}
|
||||
@@ -1754,6 +1842,10 @@ export function SetDVKLabel(arg1, arg2) {
|
||||
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SetKenwoodKeySpeed(arg1) {
|
||||
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
|
||||
}
|
||||
|
||||
export function SetPassphrase(arg1) {
|
||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||
}
|
||||
@@ -1874,6 +1966,10 @@ export function SyncPOTAHunterLog(arg1, arg2) {
|
||||
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function TailLogFile(arg1) {
|
||||
return window['go']['main']['App']['TailLogFile'](arg1);
|
||||
}
|
||||
|
||||
export function TestCloudlogUpload() {
|
||||
return window['go']['main']['App']['TestCloudlogUpload']();
|
||||
}
|
||||
@@ -1914,8 +2010,8 @@ export function TestQRZUpload() {
|
||||
return window['go']['main']['App']['TestQRZUpload']();
|
||||
}
|
||||
|
||||
export function TestRotator(arg1) {
|
||||
return window['go']['main']['App']['TestRotator'](arg1);
|
||||
export function TestRotatorDevice(arg1, arg2) {
|
||||
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function TestStationDevice(arg1) {
|
||||
@@ -2014,6 +2110,10 @@ export function WinkeyerStop() {
|
||||
return window['go']['main']['App']['WinkeyerStop']();
|
||||
}
|
||||
|
||||
export function WinkeyerTraceEnabled() {
|
||||
return window['go']['main']['App']['WinkeyerTraceEnabled']();
|
||||
}
|
||||
|
||||
export function WorkedBefore(arg1, arg2) {
|
||||
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
||||
}
|
||||
|
||||
+106
-26
@@ -316,6 +316,7 @@ export namespace award {
|
||||
leading_str?: string;
|
||||
trailing_str?: string;
|
||||
dynamic?: boolean;
|
||||
one_ref_per_qso?: boolean;
|
||||
or_rules?: OrRule[];
|
||||
dxcc_filter: number[];
|
||||
valid_bands?: string[];
|
||||
@@ -356,6 +357,7 @@ export namespace award {
|
||||
this.leading_str = source["leading_str"];
|
||||
this.trailing_str = source["trailing_str"];
|
||||
this.dynamic = source["dynamic"];
|
||||
this.one_ref_per_qso = source["one_ref_per_qso"];
|
||||
this.or_rules = this.convertValues(source["or_rules"], OrRule);
|
||||
this.dxcc_filter = source["dxcc_filter"];
|
||||
this.valid_bands = source["valid_bands"];
|
||||
@@ -461,6 +463,8 @@ export namespace award {
|
||||
ref_count: number;
|
||||
steps: Step[];
|
||||
manual?: string[];
|
||||
superseded?: string[];
|
||||
ambiguous?: string[];
|
||||
result: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -476,6 +480,8 @@ export namespace award {
|
||||
this.ref_count = source["ref_count"];
|
||||
this.steps = this.convertValues(source["steps"], Step);
|
||||
this.manual = source["manual"];
|
||||
this.superseded = source["superseded"];
|
||||
this.ambiguous = source["ambiguous"];
|
||||
this.result = source["result"];
|
||||
}
|
||||
|
||||
@@ -506,6 +512,8 @@ export namespace award {
|
||||
worked: boolean;
|
||||
confirmed: boolean;
|
||||
validated: boolean;
|
||||
modes: string[];
|
||||
confirmed_modes: string[];
|
||||
bands: string[];
|
||||
confirmed_bands: string[];
|
||||
validated_bands: string[];
|
||||
@@ -523,6 +531,8 @@ export namespace award {
|
||||
this.worked = source["worked"];
|
||||
this.confirmed = source["confirmed"];
|
||||
this.validated = source["validated"];
|
||||
this.modes = source["modes"];
|
||||
this.confirmed_modes = source["confirmed_modes"];
|
||||
this.bands = source["bands"];
|
||||
this.confirmed_bands = source["confirmed_bands"];
|
||||
this.validated_bands = source["validated_bands"];
|
||||
@@ -1297,6 +1307,7 @@ export namespace extsvc {
|
||||
hrdlog: ServiceConfig;
|
||||
eqsl: ServiceConfig;
|
||||
cloudlog: ServiceConfig;
|
||||
delete_remote: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ExternalServices(source);
|
||||
@@ -1310,6 +1321,7 @@ export namespace extsvc {
|
||||
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
|
||||
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
||||
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
|
||||
this.delete_remote = source["delete_remote"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -1430,6 +1442,8 @@ export namespace lotwusers {
|
||||
is_user: boolean;
|
||||
last_upload?: string;
|
||||
days_ago: number;
|
||||
feed_date?: string;
|
||||
feed_stale: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Info(source);
|
||||
@@ -1440,6 +1454,8 @@ export namespace lotwusers {
|
||||
this.is_user = source["is_user"];
|
||||
this.last_upload = source["last_upload"];
|
||||
this.days_ago = source["days_ago"];
|
||||
this.feed_date = source["feed_date"];
|
||||
this.feed_stale = source["feed_stale"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1504,6 +1520,7 @@ export namespace main {
|
||||
transport: string;
|
||||
host: string;
|
||||
port: number;
|
||||
password: string;
|
||||
com_port: string;
|
||||
baud: number;
|
||||
freq_out: boolean;
|
||||
@@ -1524,6 +1541,7 @@ export namespace main {
|
||||
this.transport = source["transport"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.password = source["password"];
|
||||
this.com_port = source["com_port"];
|
||||
this.baud = source["baud"];
|
||||
this.freq_out = source["freq_out"];
|
||||
@@ -1602,6 +1620,7 @@ export namespace main {
|
||||
from_gain: number;
|
||||
mic_gain: number;
|
||||
tx_gain: number;
|
||||
qso_play_gain: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AudioSettings(source);
|
||||
@@ -1622,6 +1641,7 @@ export namespace main {
|
||||
this.from_gain = source["from_gain"];
|
||||
this.mic_gain = source["mic_gain"];
|
||||
this.tx_gain = source["tx_gain"];
|
||||
this.qso_play_gain = source["qso_play_gain"];
|
||||
}
|
||||
}
|
||||
export class AutostartLaunchResult {
|
||||
@@ -1904,8 +1924,15 @@ export namespace main {
|
||||
xiegu_port: string;
|
||||
xiegu_baud: number;
|
||||
xiegu_addr: number;
|
||||
xiegu_ptt_line: string;
|
||||
yaesu_port: string;
|
||||
yaesu_baud: number;
|
||||
kenwood_host: string;
|
||||
kenwood_port: string;
|
||||
kenwood_baud: number;
|
||||
kenwood_data_mode: string;
|
||||
yaesu_low_lines: boolean;
|
||||
kenwood_low_lines: boolean;
|
||||
icom_port: string;
|
||||
icom_baud: number;
|
||||
icom_addr: number;
|
||||
@@ -1921,6 +1948,9 @@ export namespace main {
|
||||
digital_default: string;
|
||||
share_enabled: boolean;
|
||||
share_port: number;
|
||||
ptt_hotkey_enabled: boolean;
|
||||
ptt_hotkey: string;
|
||||
ptt_hotkey_toggle: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new CATSettings(source);
|
||||
@@ -1940,8 +1970,15 @@ export namespace main {
|
||||
this.xiegu_port = source["xiegu_port"];
|
||||
this.xiegu_baud = source["xiegu_baud"];
|
||||
this.xiegu_addr = source["xiegu_addr"];
|
||||
this.xiegu_ptt_line = source["xiegu_ptt_line"];
|
||||
this.yaesu_port = source["yaesu_port"];
|
||||
this.yaesu_baud = source["yaesu_baud"];
|
||||
this.kenwood_host = source["kenwood_host"];
|
||||
this.kenwood_port = source["kenwood_port"];
|
||||
this.kenwood_baud = source["kenwood_baud"];
|
||||
this.kenwood_data_mode = source["kenwood_data_mode"];
|
||||
this.yaesu_low_lines = source["yaesu_low_lines"];
|
||||
this.kenwood_low_lines = source["kenwood_low_lines"];
|
||||
this.icom_port = source["icom_port"];
|
||||
this.icom_baud = source["icom_baud"];
|
||||
this.icom_addr = source["icom_addr"];
|
||||
@@ -1957,6 +1994,9 @@ export namespace main {
|
||||
this.digital_default = source["digital_default"];
|
||||
this.share_enabled = source["share_enabled"];
|
||||
this.share_port = source["share_port"];
|
||||
this.ptt_hotkey_enabled = source["ptt_hotkey_enabled"];
|
||||
this.ptt_hotkey = source["ptt_hotkey"];
|
||||
this.ptt_hotkey_toggle = source["ptt_hotkey_toggle"];
|
||||
}
|
||||
}
|
||||
export class CabrilloResult {
|
||||
@@ -2686,6 +2726,7 @@ export namespace main {
|
||||
ref: string;
|
||||
name?: string;
|
||||
pickable: boolean;
|
||||
ambiguous?: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new QSOAwardRef(source);
|
||||
@@ -2697,6 +2738,7 @@ export namespace main {
|
||||
this.ref = source["ref"];
|
||||
this.name = source["name"];
|
||||
this.pickable = source["pickable"];
|
||||
this.ambiguous = source["ambiguous"];
|
||||
}
|
||||
}
|
||||
export class QSORate {
|
||||
@@ -2772,11 +2814,52 @@ export namespace main {
|
||||
}
|
||||
}
|
||||
|
||||
export class RotatorDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
has_elevation: boolean;
|
||||
rotator_num: number;
|
||||
dual: boolean;
|
||||
motorized: boolean;
|
||||
name2: string;
|
||||
motorized2: boolean;
|
||||
transport: string;
|
||||
com_port: string;
|
||||
baud: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RotatorDevice(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.type = source["type"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.has_elevation = source["has_elevation"];
|
||||
this.rotator_num = source["rotator_num"];
|
||||
this.dual = source["dual"];
|
||||
this.motorized = source["motorized"];
|
||||
this.name2 = source["name2"];
|
||||
this.motorized2 = source["motorized2"];
|
||||
this.transport = source["transport"];
|
||||
this.com_port = source["com_port"];
|
||||
this.baud = source["baud"];
|
||||
}
|
||||
}
|
||||
export class RotatorHeading {
|
||||
enabled: boolean;
|
||||
ok: boolean;
|
||||
azimuth: number;
|
||||
raw: string;
|
||||
rotors: string[];
|
||||
active: number;
|
||||
motorized: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RotatorHeading(source);
|
||||
@@ -2788,32 +2871,9 @@ export namespace main {
|
||||
this.ok = source["ok"];
|
||||
this.azimuth = source["azimuth"];
|
||||
this.raw = source["raw"];
|
||||
}
|
||||
}
|
||||
export class RotatorSettings {
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
has_elevation: boolean;
|
||||
rotator_num: number;
|
||||
transport: string;
|
||||
com_port: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RotatorSettings(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.type = source["type"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.has_elevation = source["has_elevation"];
|
||||
this.rotator_num = source["rotator_num"];
|
||||
this.transport = source["transport"];
|
||||
this.com_port = source["com_port"];
|
||||
this.rotors = source["rotors"];
|
||||
this.active = source["active"];
|
||||
this.motorized = source["motorized"];
|
||||
}
|
||||
}
|
||||
export class ScpStatus {
|
||||
@@ -2874,6 +2934,8 @@ export namespace main {
|
||||
worked_call: boolean;
|
||||
new_county: boolean;
|
||||
new_pota: boolean;
|
||||
new_pfx: boolean;
|
||||
pfx?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SpotStatus(source);
|
||||
@@ -2890,6 +2952,8 @@ export namespace main {
|
||||
this.worked_call = source["worked_call"];
|
||||
this.new_county = source["new_county"];
|
||||
this.new_pota = source["new_pota"];
|
||||
this.new_pfx = source["new_pfx"];
|
||||
this.pfx = source["pfx"];
|
||||
}
|
||||
}
|
||||
export class StartupStatus {
|
||||
@@ -3092,6 +3156,7 @@ export namespace main {
|
||||
follow: boolean;
|
||||
step_khz: number;
|
||||
tx_inhibit: boolean;
|
||||
bands: string[];
|
||||
freq_min_mhz: number;
|
||||
freq_max_mhz: number;
|
||||
|
||||
@@ -3111,6 +3176,7 @@ export namespace main {
|
||||
this.follow = source["follow"];
|
||||
this.step_khz = source["step_khz"];
|
||||
this.tx_inhibit = source["tx_inhibit"];
|
||||
this.bands = source["bands"];
|
||||
this.freq_min_mhz = source["freq_min_mhz"];
|
||||
this.freq_max_mhz = source["freq_max_mhz"];
|
||||
}
|
||||
@@ -3471,6 +3537,11 @@ export namespace powergenius {
|
||||
fan_mode?: string;
|
||||
temperature: number;
|
||||
operate: boolean;
|
||||
fwd_w: number;
|
||||
peak_w: number;
|
||||
vswr: number;
|
||||
id: number;
|
||||
peak_id: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Status(source);
|
||||
@@ -3485,6 +3556,11 @@ export namespace powergenius {
|
||||
this.fan_mode = source["fan_mode"];
|
||||
this.temperature = source["temperature"];
|
||||
this.operate = source["operate"];
|
||||
this.fwd_w = source["fwd_w"];
|
||||
this.peak_w = source["peak_w"];
|
||||
this.vswr = source["vswr"];
|
||||
this.id = source["id"];
|
||||
this.peak_id = source["peak_id"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4530,6 +4606,7 @@ export namespace spe {
|
||||
|
||||
export class Status {
|
||||
connected: boolean;
|
||||
transport: string;
|
||||
last_error?: string;
|
||||
model?: string;
|
||||
operate: boolean;
|
||||
@@ -4553,6 +4630,7 @@ export namespace spe {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.connected = source["connected"];
|
||||
this.transport = source["transport"];
|
||||
this.last_error = source["last_error"];
|
||||
this.model = source["model"];
|
||||
this.operate = source["operate"];
|
||||
@@ -4607,6 +4685,7 @@ export namespace tunergenius {
|
||||
last_error?: string;
|
||||
fwd_dbm: number;
|
||||
fwd_w: number;
|
||||
peak_w: number;
|
||||
swr_db: number;
|
||||
vswr: number;
|
||||
operate: boolean;
|
||||
@@ -4634,6 +4713,7 @@ export namespace tunergenius {
|
||||
this.last_error = source["last_error"];
|
||||
this.fwd_dbm = source["fwd_dbm"];
|
||||
this.fwd_w = source["fwd_w"];
|
||||
this.peak_w = source["peak_w"];
|
||||
this.swr_db = source["swr_db"];
|
||||
this.vswr = source["vswr"];
|
||||
this.operate = source["operate"];
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# ACOM built-in ATU — what we know, and why OpsLog does not drive it
|
||||
|
||||
Session of 2026-08-02 on F4BPO's friend's **ACOM 500S**, serial link, telemetry
|
||||
trace. Written down so nobody repeats the work. **Conclusion first: the tuner
|
||||
cannot be started over the serial link, and the attempt to work around that was
|
||||
abandoned.**
|
||||
|
||||
## The tuner is PA state 8
|
||||
|
||||
Frame byte 3's high nibble is the PA status. ACOM's own list (recovered by the
|
||||
ACOM-Controller project) has no entry for 8; it is the antenna tuner. Confirmed
|
||||
twice, front-panel TUNE, with the byte-level trace running:
|
||||
|
||||
```
|
||||
14:47:20.745 state 8 step 0 ← TUNE pressed. err=FF
|
||||
14:47:22.588 operator keys a carrier (FT-891, FM, 31 W, 3.6358 MHz)
|
||||
14:47:24.006 state 8 step 2
|
||||
14:47:25.201 state 8 step 4
|
||||
14:47:27.457 state 8 step 5
|
||||
14:47:30.092 state 5 STANDBY ← accord finished
|
||||
```
|
||||
|
||||
Byte 3's **low** nibble is a step counter through the accord: `0 → 2 → 4 → 5`.
|
||||
Step 0 means *armed, waiting for RF* — in the first of the two runs the
|
||||
amplifier sat there **30 seconds** untouched before the operator transmitted.
|
||||
|
||||
`paStatusNames[8] = "TUNE"` in acom.go is the only thing kept from all of this.
|
||||
|
||||
## The command does not exist in the state-command family
|
||||
|
||||
Every action byte of `55 81 08 02 00 XX 00 CHK` was tried, each one from a
|
||||
**verified** STANDBY (an earlier sweep was worthless because its second code
|
||||
parked the amp in SERVICE, where it silently refuses everything — 17 false
|
||||
negatives). Result:
|
||||
|
||||
| byte | effect |
|
||||
|------|--------|
|
||||
| 0x01, 0x02 | **reboot the amplifier** — it prints `AMPLIFIER: ACOM 500S / HELLO ME…` |
|
||||
| 0x04 | → SERVICE (state 4) |
|
||||
| 0x05 | → STANDBY (state 5) |
|
||||
| 0x06 | → OPERATE (state 6) |
|
||||
| 0x0A | → OFF (state 10) |
|
||||
| 0x03, 0x07..0x09, 0x0B..0x18 | nothing, state unchanged |
|
||||
|
||||
The action byte **is** the target PA state — four independent confirmations.
|
||||
Which makes 0x08 the obvious candidate for TUNE. **It was tried, from a verified
|
||||
STANDBY, and does nothing.** State 8 is reachable from the front panel only.
|
||||
|
||||
Going further would mean varying the other bytes of the frame. That is a large
|
||||
space, and 0x01/0x02 prove that bad values reboot a 500 W amplifier. Not worth
|
||||
it.
|
||||
|
||||
## The workaround that was built and removed
|
||||
|
||||
Since the amp waits, armed, for a carrier, OpsLog could watch for state 8 and
|
||||
supply one: memorise the mode, drop the power, switch to FM, key the PTT, wait
|
||||
for state 8 to clear, unkey, restore. It was implemented (`acomtune.go`, opt-in,
|
||||
Settings → Amplifier) and **removed** after the first hardware test:
|
||||
|
||||
- the power change did not take effect, and
|
||||
- **the PTT was not released when the tune finished.**
|
||||
|
||||
A logger that can leave a transceiver keyed is not acceptable, and the whole
|
||||
thing was scaffolding around a protocol we do not actually control. Removed at
|
||||
the operator's request: *"je veux qu'OpsLog soit solide et ça c'est de la
|
||||
bidouille."*
|
||||
|
||||
If it is ever revisited, the unexplained part is why the tune-finished detection
|
||||
did not fire — `anyAcomTuning()` polled `StateRaw == 8` every 200 ms and should
|
||||
have seen the drop to STANDBY. Suspect the status snapshot, not the amplifier.
|
||||
|
||||
## Loose ends worth knowing
|
||||
|
||||
- **Frame byte 6 is a multiplexer.** The tail of the frame changes meaning with
|
||||
it: `0x93` = bytes 48/49 carry the TX frequency in kHz (`62 1B` = 7010 while
|
||||
the rig was on 7.010 MHz), `0x9D` = something else. Byte 70 looked like a
|
||||
tuner flag for a while purely because of this — it toggles on its own several
|
||||
times a minute. Do not read tail bytes without checking byte 6.
|
||||
- **Byte 5 bit 0x80 alternates frame to frame.** It is a sequence/parity bit,
|
||||
not a command acknowledgement.
|
||||
- **Byte 66 (the error code) shows 0x14 / 0x8E / 0x69 / 0x0F during a normal
|
||||
tune and during transmission.** `errText` renders those as "ERROR — see
|
||||
display" and "Remove drive power", so an ACOM operator probably sees a false
|
||||
alarm. This was never confirmed with the amplifier in OPERATE (it was in
|
||||
STANDBY throughout, where drive genuinely is an error) — worth a look with a
|
||||
clean log before touching the table.
|
||||
@@ -77,9 +77,14 @@ var models = map[string]model{
|
||||
}
|
||||
|
||||
// paStatusNames maps the PAstatus nibble to a display string.
|
||||
// 8 is absent from the state list the ACOM-Controller project recovered; it is
|
||||
// the antenna tuner, read off F4BPO's 500S on 2026-08-02. Pressing TUNE on the
|
||||
// front panel took byte 3 from 0x51 to 0x80, the amp then WAITED 19 s doing
|
||||
// nothing until the operator keyed a carrier, ran the tune over ~8 s while the
|
||||
// low nibble stepped 0→2→4→5, and dropped back to 0x51 (STANDBY).
|
||||
var paStatusNames = map[int]string{
|
||||
1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE",
|
||||
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 9: "SYSTEM", 10: "OFF",
|
||||
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 8: "TUNE", 9: "SYSTEM", 10: "OFF",
|
||||
}
|
||||
|
||||
// acomBands maps the band nibble to a band label.
|
||||
|
||||
@@ -46,6 +46,21 @@ type Importer struct {
|
||||
// Used to recompute country / zones from cty.dat so a bad COUNTRY in the
|
||||
// source file (common with contest loggers) is corrected on the way in.
|
||||
Enrich func(*qso.QSO)
|
||||
// FieldMap moves ADIF fields around before a record is converted, keyed
|
||||
// lowercase source → lowercase destination.
|
||||
//
|
||||
// Contest software puts things where the operator, not the standard, expects
|
||||
// them. The RSGB IOTA contest is the case that prompted this: N1MM and
|
||||
// friends export the island reference in STATE, because that is the column
|
||||
// their contest module uses for the received exchange. Imported verbatim,
|
||||
// every QSO gets a US-state field holding "EU005" and the IOTA award sees
|
||||
// nothing at all.
|
||||
//
|
||||
// Applied to the RAW record, so it works for promoted columns and Extras
|
||||
// alike, and so the destination is parsed exactly as if the file had carried
|
||||
// it there in the first place. A destination that already has a value is
|
||||
// never overwritten — the file's own IOTA tag outranks a guess about STATE.
|
||||
FieldMap map[string]string
|
||||
// OnProgress, when set, is called periodically with (processed, total)
|
||||
// record counts so the UI can show a progress bar. total is an estimate
|
||||
// from counting <EOR> tags up front.
|
||||
@@ -161,6 +176,7 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
||||
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
|
||||
res.Total++
|
||||
reportProgress(false)
|
||||
applyFieldMap(rec, im.FieldMap)
|
||||
q, ok := recordToQSO(rec)
|
||||
if !ok {
|
||||
res.Skipped++
|
||||
@@ -303,6 +319,49 @@ func stringSet(items ...string) map[string]struct{} {
|
||||
// RecordToQSO is the exported alias used by the UDP auto-log path so it
|
||||
// can convert a freshly received ADIF record into a QSO and then enrich
|
||||
// it with lookup + operating data before inserting.
|
||||
// applyFieldMap moves values between tags of a record, in place.
|
||||
//
|
||||
// The source is CLEARED after the move: a reference that belongs in IOTA should
|
||||
// not also be left behind in STATE, where it would show up as the contacted
|
||||
// station's US state in the grid and in every export.
|
||||
func applyFieldMap(rec Record, m map[string]string) {
|
||||
if len(m) == 0 || rec == nil {
|
||||
return
|
||||
}
|
||||
// Read every source first. Applied one at a time, a swap (a→b, b→a) would
|
||||
// read back what the previous move had just written.
|
||||
orig := make(map[string]string, len(rec))
|
||||
for k, v := range rec {
|
||||
orig[k] = v
|
||||
}
|
||||
vals := make(map[string]string, len(m))
|
||||
for from := range m {
|
||||
if v := strings.TrimSpace(rec[from]); v != "" {
|
||||
vals[from] = v
|
||||
}
|
||||
}
|
||||
for from := range m {
|
||||
delete(rec, from)
|
||||
}
|
||||
for from, to := range m {
|
||||
v, ok := vals[from]
|
||||
if !ok || from == to || to == "" {
|
||||
continue
|
||||
}
|
||||
// Whether the destination was already filled is judged against the record
|
||||
// as it ARRIVED, not as we are rewriting it — otherwise the outcome would
|
||||
// depend on Go's map iteration order, which is deliberately random.
|
||||
//
|
||||
// A destination that is itself a mapping source is an explicit swap, and
|
||||
// overwriting it is the whole point; anything else keeps what the file
|
||||
// carried, because the file's own tag outranks a guess about where a
|
||||
// reference might have been put.
|
||||
if _, swap := m[to]; swap || strings.TrimSpace(orig[to]) == "" {
|
||||
rec[to] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RecordToQSO(rec Record) (qso.QSO, bool) { return recordToQSO(rec) }
|
||||
|
||||
// recordToQSO maps an ADIF record onto a QSO. Returns false if required
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package adif_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/adif"
|
||||
"hamlog/internal/db"
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// End to end: an operator's own record, through a real SQLite logbook and back.
|
||||
//
|
||||
// Written because CONTEST_ID was reported as "not imported". It is imported, and
|
||||
// this pins the whole path — parse, remap, insert, read back — rather than the
|
||||
// conversion step alone, which was already green while the report stood. A field
|
||||
// can be lost at five places between the file and the grid; only the round trip
|
||||
// says which.
|
||||
func TestContestIDSurvivesImport(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
conn, err := db.Open(filepath.Join(dir, "logbook.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
const file = "<CALL:6>2E0CVN <QSO_DATE:8>20260725 <TIME_ON:6>120039 <STATE:5>EU005 <BAND:3>20M <FREQ:6>14.014 <MODE:2>CW <CONTEST_ID:9>RSGB-IOTA <RST_RCVD:3>599 <RST_SENT:3>599 <SRX_STRING:8>001EU005 <EOR>\n"
|
||||
path := filepath.Join(dir, "in.adi")
|
||||
if err := os.WriteFile(path, []byte(file), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repo := qso.NewRepo(conn)
|
||||
im := &adif.Importer{Repo: repo, FieldMap: map[string]string{"state": "iota"}}
|
||||
res, err := im.ImportFile(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
t.Logf("result: %+v", res)
|
||||
|
||||
rows, err := conn.Query("SELECT callsign, contest_id, iota, state FROM qso")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
n := 0
|
||||
for rows.Next() {
|
||||
var call, contest, iota, state any
|
||||
if err := rows.Scan(&call, &contest, &iota, &state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n++
|
||||
t.Logf("stored: call=%v contest_id=%v iota=%v state=%v", call, contest, iota, state)
|
||||
if s := strings.TrimSpace(toS(contest)); s != "RSGB-IOTA" {
|
||||
t.Errorf("contest_id in the DATABASE = %q, want RSGB-IOTA", s)
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("nothing was stored")
|
||||
}
|
||||
}
|
||||
|
||||
func toS(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case []byte:
|
||||
return string(x)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package adif
|
||||
|
||||
import "testing"
|
||||
|
||||
// Field mapping, from the RSGB IOTA contest case.
|
||||
//
|
||||
// N1MM and similar export the island reference in STATE, because that is the
|
||||
// column their contest module uses for the received exchange. Imported
|
||||
// verbatim, every QSO gets a US-state field reading "EU005" and the IOTA award
|
||||
// sees nothing — the reference is in the log but not where anything looks.
|
||||
func TestApplyFieldMap(t *testing.T) {
|
||||
// The real record, from an operator's TM2Q log.
|
||||
rec := Record{
|
||||
"call": "2E0CVN", "band": "20m", "mode": "CW",
|
||||
"state": "EU005", "srx_string": "001EU005",
|
||||
}
|
||||
applyFieldMap(rec, map[string]string{"state": "iota"})
|
||||
if rec["iota"] != "EU005" {
|
||||
t.Errorf("iota = %q, want EU005", rec["iota"])
|
||||
}
|
||||
// Cleared, not copied: a reference left behind in STATE would show up as the
|
||||
// contacted station's US state in the grid and in every export.
|
||||
if _, ok := rec["state"]; ok {
|
||||
t.Errorf("state survived the move as %q", rec["state"])
|
||||
}
|
||||
|
||||
// A destination the file already filled is never overwritten — the file's own
|
||||
// tag outranks a guess about where the reference might be.
|
||||
rec2 := Record{"call": "UT0RM", "state": "EU005", "iota": "EU030"}
|
||||
applyFieldMap(rec2, map[string]string{"state": "iota"})
|
||||
if rec2["iota"] != "EU030" {
|
||||
t.Errorf("iota = %q — the file's own value must win", rec2["iota"])
|
||||
}
|
||||
|
||||
// An empty source moves nothing (the second QSO of that log has no STATE).
|
||||
rec3 := Record{"call": "UT0RM", "state": " "}
|
||||
applyFieldMap(rec3, map[string]string{"state": "iota"})
|
||||
if v, ok := rec3["iota"]; ok {
|
||||
t.Errorf("an empty source created iota=%q", v)
|
||||
}
|
||||
|
||||
// A swap reads both sources before writing either.
|
||||
rec4 := Record{"state": "EU005", "iota": "NA001"}
|
||||
applyFieldMap(rec4, map[string]string{"state": "iota", "iota": "state"})
|
||||
if rec4["iota"] != "EU005" || rec4["state"] != "NA001" {
|
||||
t.Errorf("swap gave iota=%q state=%q, want EU005 / NA001", rec4["iota"], rec4["state"])
|
||||
}
|
||||
|
||||
// No mapping, no change.
|
||||
rec5 := Record{"call": "F4BPO", "state": "EU005"}
|
||||
applyFieldMap(rec5, nil)
|
||||
if rec5["state"] != "EU005" {
|
||||
t.Error("a nil mapping must leave the record alone")
|
||||
}
|
||||
}
|
||||
@@ -101,3 +101,30 @@ func endpointName(dev *wca.IMMDevice, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// DeviceName resolves an endpoint id to its friendly name.
|
||||
//
|
||||
// Diagnostics quote the id that was CONFIGURED, which is a GUID — an operator
|
||||
// told "no audio at all from {0.0.1.00000000}.{6a27abfd…}" learns nothing they
|
||||
// can act on, while "no audio at all from DAX RX 1 (FlexRadio DAX)" points
|
||||
// straight at the DAX panel.
|
||||
//
|
||||
// Falls back to the id when the endpoint cannot be found, which is itself worth
|
||||
// seeing: a device that has disappeared explains an empty recording too.
|
||||
func DeviceName(id string) string {
|
||||
if id == "" {
|
||||
return "(none)"
|
||||
}
|
||||
for _, list := range []func() ([]Device, error){ListInputDevices, ListOutputDevices} {
|
||||
devs, err := list()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, d := range devs {
|
||||
if d.ID == id {
|
||||
return d.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -80,7 +80,17 @@ func pcmFormat() *wca.WAVEFORMATEX {
|
||||
}
|
||||
}
|
||||
|
||||
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | wca.AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY
|
||||
// autoConvert lets WASAPI resample between the device format and ours.
|
||||
//
|
||||
// SRC_DEFAULT_QUALITY used to be set alongside it, and that name is misleading:
|
||||
// it selects the CHEAP converter, meant for cases where quality does not matter.
|
||||
// Going from a rig 48 kHz stream down to our 16 kHz, it folds everything above
|
||||
// 8 kHz back into the audio band — and a receiver hiss is mostly high
|
||||
// frequencies. The result was a recording where the noise sat ON TOP of the
|
||||
// voice, while the same audio heard live had the voice well clear of it.
|
||||
//
|
||||
// Without the flag Windows uses its normal converter, which filters first.
|
||||
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
|
||||
|
||||
// recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the
|
||||
// stop channel is closed.
|
||||
|
||||
+89
-10
@@ -5,16 +5,28 @@ package audio
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Manager owns the DVK record/playback lifecycle: at most one recording and
|
||||
// one playback at a time. Device ids are passed per call so the host can route
|
||||
// recording to the mic and playback to the rig (or the preview speakers).
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
recStop chan struct{}
|
||||
recDone chan recResult
|
||||
playStop chan struct{}
|
||||
mu sync.Mutex
|
||||
recStop chan struct{}
|
||||
recDone chan recResult
|
||||
// monGainPct scales what the RX monitor plays, 100 = as captured.
|
||||
//
|
||||
// The "From radio" slider used to reach only the QSO recorder, so an
|
||||
// operator listening through OpsLog heard no difference between 10% and
|
||||
// 150% — the setting looked broken because it was, for the thing they were
|
||||
// listening to.
|
||||
monGainPct int
|
||||
playStop chan struct{}
|
||||
// playDone closes when the playback goroutine has returned and the audio
|
||||
// device is free again. Without it, the next Play raced the old one for the
|
||||
// device and lost.
|
||||
playDone chan struct{}
|
||||
monStop chan struct{} // RX monitor passthrough (capture → render)
|
||||
monRing *pcmRing // live audio hand-off, also fed by the network stream
|
||||
txStop chan struct{} // TX audio passthrough (mic → rig)
|
||||
@@ -125,18 +137,38 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error {
|
||||
pcm[i], pcm[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
|
||||
}
|
||||
}
|
||||
// Waits for any previous playback to have RELEASED THE DEVICE.
|
||||
//
|
||||
// It used to only signal the old one to stop and start a new one at once.
|
||||
// The old goroutine still held the WASAPI render client for a moment, so the
|
||||
// new client could not start: it returned immediately, the PTT was keyed and
|
||||
// released a tenth of a second later, and nothing came out. On the air that
|
||||
// looked like "press play again and you must wait the whole length of the
|
||||
// message before it will play at all".
|
||||
m.StopPlayback()
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
m.mu.Lock()
|
||||
m.playStop = stop
|
||||
m.playDone = done
|
||||
m.mu.Unlock()
|
||||
go func() {
|
||||
_ = playPCM(deviceID, pcm, rate, ch, bits, stop)
|
||||
// The error was discarded. A device that refuses to start returns here
|
||||
// instantly, the PTT is released 120 ms later, and NOTHING says why —
|
||||
// which is exactly what a station heard as "it plays once, then never
|
||||
// again": the call succeeded, the sound did not.
|
||||
if err := playPCM(deviceID, pcm, rate, ch, bits, stop); err != nil {
|
||||
LogSink("audio: playback on %q failed: %v", DeviceName(deviceID), err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.playStop == stop {
|
||||
m.playStop = nil
|
||||
}
|
||||
if m.playDone == done {
|
||||
m.playDone = nil
|
||||
}
|
||||
m.mu.Unlock()
|
||||
close(done) // the device is free from here
|
||||
m.notify()
|
||||
}()
|
||||
m.notify()
|
||||
@@ -147,12 +179,24 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error {
|
||||
func (m *Manager) StopPlayback() {
|
||||
m.mu.Lock()
|
||||
stop := m.playStop
|
||||
done := m.playDone
|
||||
m.playStop = nil
|
||||
m.mu.Unlock()
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
m.notify()
|
||||
if stop == nil {
|
||||
return
|
||||
}
|
||||
close(stop)
|
||||
// Wait for the goroutine to release the device — that is the whole point of
|
||||
// stopping before starting again. Bounded: a wedged WASAPI call must not
|
||||
// freeze the caller, which here is the operator clicking a button.
|
||||
if done != nil {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
LogSink("audio: previous playback did not release the device within 1.5 s")
|
||||
}
|
||||
}
|
||||
m.notify()
|
||||
}
|
||||
|
||||
// ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------
|
||||
@@ -188,9 +232,12 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
||||
m.mu.Unlock()
|
||||
|
||||
if capture {
|
||||
// Producer: capture the rig's USB audio into the ring.
|
||||
// Producer: capture the rig's USB audio into the ring, at the level the
|
||||
// operator set. Applied HERE rather than on the render side so the
|
||||
// network-fed path (PushMonitorAudio) keeps its own untouched levels —
|
||||
// that stream is already scaled by the radio.
|
||||
go func() {
|
||||
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) })
|
||||
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(m.scaleMonitor(chunk)) })
|
||||
}()
|
||||
}
|
||||
// Consumer: render the ring to the output device at the internal 16 kHz mono.
|
||||
@@ -201,6 +248,38 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMonitorGain sets the RX monitor level in percent (100 = as captured).
|
||||
// Takes effect on the next captured chunk — no need to restart the monitor.
|
||||
func (m *Manager) SetMonitorGain(pct int) {
|
||||
if pct <= 0 {
|
||||
pct = 100
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.monGainPct = pct
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// scaleMonitor applies the monitor level to one captured chunk, returning a
|
||||
// buffer the ring may keep. At unity it hands the chunk straight back: the
|
||||
// common case must not pay for a copy 30 times a second.
|
||||
func (m *Manager) scaleMonitor(chunk []byte) []byte {
|
||||
m.mu.Lock()
|
||||
pct := m.monGainPct
|
||||
m.mu.Unlock()
|
||||
if pct == 0 || pct == 100 {
|
||||
return chunk
|
||||
}
|
||||
g := float64(pct) / 100
|
||||
out := make([]byte, len(chunk))
|
||||
copy(out, chunk)
|
||||
for i := 0; i+1 < len(out); i += 2 {
|
||||
v := int16(uint16(out[i]) | uint16(out[i+1])<<8)
|
||||
v = scalePCM(v, g)
|
||||
out[i], out[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StopMonitor stops the RX monitor passthrough.
|
||||
func (m *Manager) StopMonitor() {
|
||||
m.mu.Lock()
|
||||
|
||||
+70
-7
@@ -3,6 +3,7 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
|
||||
"github.com/braheezy/shine-mp3/pkg/mp3"
|
||||
@@ -50,19 +51,81 @@ func writeMP3(path string, pcm []byte) error {
|
||||
return enc.Write(f, stereo)
|
||||
}
|
||||
|
||||
// upsample2 doubles the sample rate with linear interpolation (16 kHz → 32 kHz).
|
||||
// upsample2 doubles the sample rate, 16 kHz → 32 kHz, WITH the interpolation
|
||||
// filter that makes the operation legitimate.
|
||||
//
|
||||
// It used to interpolate linearly — each new sample the average of its
|
||||
// neighbours. That is an upsampler in name only: every component at f is
|
||||
// mirrored to 16 kHz − f, measured just 7 dB down (see the test). On speech it
|
||||
// adds brightness; on receiver noise, which is broadband, it lays a second
|
||||
// noise floor across the top of the band. Operators heard it as a hiss louder
|
||||
// than the voice, present in the MP3 and absent from the WAV of the very same
|
||||
// audio.
|
||||
//
|
||||
// Zero-stuffing followed by a windowed-sinc low-pass at the old Nyquist is the
|
||||
// textbook answer, and it puts the image below 40 dB. 63 taps is a few hundred
|
||||
// microseconds of work on an eight-second recording — nothing, next to the MP3
|
||||
// encode that follows.
|
||||
func upsample2(in []int16) []int16 {
|
||||
if len(in) == 0 {
|
||||
return in
|
||||
}
|
||||
out := make([]int16, len(in)*2)
|
||||
for i := range in {
|
||||
out[2*i] = in[i]
|
||||
if i+1 < len(in) {
|
||||
out[2*i+1] = int16((int32(in[i]) + int32(in[i+1])) / 2)
|
||||
} else {
|
||||
out[2*i+1] = in[i]
|
||||
half := len(upsampleFIR) / 2
|
||||
for i := range out {
|
||||
var acc float64
|
||||
// The zero-stuffed signal is non-zero only at even indices, so only
|
||||
// every other tap contributes — the loop skips the zeros rather than
|
||||
// multiplying by them.
|
||||
start := (i - half + 1) &^ 1 // first even index in the window
|
||||
for j := start; j <= i+half; j += 2 {
|
||||
k := i - j + half
|
||||
if k < 0 || k >= len(upsampleFIR) {
|
||||
continue
|
||||
}
|
||||
src := j / 2
|
||||
if src < 0 || src >= len(in) {
|
||||
continue
|
||||
}
|
||||
acc += float64(in[src]) * upsampleFIR[k]
|
||||
}
|
||||
// ×2 for the energy lost to zero-stuffing, then clamp.
|
||||
v := acc * 2
|
||||
if v > 32767 {
|
||||
v = 32767
|
||||
} else if v < -32768 {
|
||||
v = -32768
|
||||
}
|
||||
out[i] = int16(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// upsampleFIR is a 63-tap Hamming-windowed sinc, cut off at a quarter of the
|
||||
// NEW rate (8 kHz at 32 kHz) — exactly the old Nyquist, which is where the
|
||||
// images begin.
|
||||
var upsampleFIR = func() []float64 {
|
||||
const n = 63
|
||||
const fc = 0.25 // cycles/sample at the new rate
|
||||
h := make([]float64, n)
|
||||
mid := (n - 1) / 2
|
||||
var sum float64
|
||||
for i := 0; i < n; i++ {
|
||||
m := float64(i - mid)
|
||||
var v float64
|
||||
if m == 0 {
|
||||
v = 2 * fc
|
||||
} else {
|
||||
v = math.Sin(2*math.Pi*fc*m) / (math.Pi * m)
|
||||
}
|
||||
// Hamming window: a rectangular one would ring and put the stopband
|
||||
// only ~21 dB down, which is not enough to be worth the filter.
|
||||
v *= 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(n-1))
|
||||
h[i] = v
|
||||
sum += v
|
||||
}
|
||||
for i := range h {
|
||||
h[i] /= sum // unity gain at DC
|
||||
}
|
||||
return h
|
||||
}()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Playing a stopped take must not consume it.
|
||||
//
|
||||
// Reported on the air: the recording plays once, then nothing — "as if it threw
|
||||
// the sound away after playing it". PeekQSO is what the playback reads, and it
|
||||
// is meant to COPY; TakeQSO is the one that clears.
|
||||
func TestPeekDoesNotConsumeTheTake(t *testing.T) {
|
||||
r := &Recorder{running: true, active: true, paused: true, prerollSamples: sampleRate}
|
||||
r.acc = make([]int16, 4000)
|
||||
for i := range r.acc {
|
||||
r.acc[i] = int16(i % 500)
|
||||
}
|
||||
|
||||
first, err := r.PeekQSO()
|
||||
if err != nil {
|
||||
t.Fatalf("first peek: %v", err)
|
||||
}
|
||||
second, err := r.PeekQSO()
|
||||
if err != nil {
|
||||
t.Fatalf("second peek: %v — the take was consumed by the first", err)
|
||||
}
|
||||
if len(first) != len(second) {
|
||||
t.Errorf("second peek returned %d bytes, first %d", len(second), len(first))
|
||||
}
|
||||
|
||||
// And the copy must be independent: the caller holds these bytes while the
|
||||
// recorder may still be appending to its own slice.
|
||||
if len(first) > 0 {
|
||||
first[0] = ^first[0]
|
||||
again, _ := r.PeekQSO()
|
||||
if len(again) > 0 && again[0] == first[0] {
|
||||
t.Error("PeekQSO handed out its internal buffer, not a copy")
|
||||
}
|
||||
}
|
||||
|
||||
// Resuming and stopping again keeps everything captured so far.
|
||||
r.ResumeQSO()
|
||||
r.mu.Lock()
|
||||
r.acc = append(r.acc, make([]int16, 1000)...)
|
||||
r.mu.Unlock()
|
||||
r.PauseQSO()
|
||||
third, err := r.PeekQSO()
|
||||
if err != nil {
|
||||
t.Fatalf("peek after resume: %v", err)
|
||||
}
|
||||
if len(third) <= len(second) {
|
||||
t.Errorf("after resuming, the take is %d bytes — it should have grown past %d", len(third), len(second))
|
||||
}
|
||||
_ = time.Now
|
||||
}
|
||||
+254
-23
@@ -15,6 +15,14 @@ import (
|
||||
// Defaults to a no-op so the package is usable without wiring.
|
||||
var LogSink = func(string, ...any) {}
|
||||
|
||||
// AlertSink receives the few audio problems an operator must see WHILE they are
|
||||
// operating, not afterwards in a log file — a capture device that opens but
|
||||
// never streams being the one that matters: the recording is silently empty and
|
||||
// nothing says so until the QSO is logged and gone.
|
||||
//
|
||||
// Set to a toast emitter at startup; a no-op keeps the package standalone.
|
||||
var AlertSink = func(string, ...any) {}
|
||||
|
||||
// recoverGoroutine turns a panic in a long-running audio goroutine into a logged
|
||||
// event with a stack trace instead of a silent process-killing crash. (It can't
|
||||
// catch a hard Windows access violation from the WASAPI layer — those are fatal
|
||||
@@ -42,16 +50,31 @@ type Recorder struct {
|
||||
prerollSamples int
|
||||
|
||||
// Per-source sample queues (guarded by srcMu), drained by the mixer.
|
||||
srcMu sync.Mutex
|
||||
bufA []int16 // From Radio
|
||||
bufB []int16 // mic
|
||||
twoSrc bool
|
||||
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
|
||||
gainB float64 // mic gain
|
||||
srcMu sync.Mutex
|
||||
bufA []int16 // From Radio
|
||||
bufB []int16 // mic
|
||||
// When each source last delivered samples. A configured device that never
|
||||
// produces anything is not an error anywhere — the capture call just sits
|
||||
// there — so the only way to notice is to watch the clock.
|
||||
lastA, lastB time.Time
|
||||
// startedAt is when capture began — the reference for a source that has not
|
||||
// delivered anything at all yet.
|
||||
startedAt time.Time
|
||||
// deadB (deadA) latches once a source has been declared silent, so the
|
||||
// warning is logged once rather than 25 times a second.
|
||||
deadA, deadB bool
|
||||
twoSrc bool
|
||||
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
|
||||
gainB float64 // mic gain
|
||||
|
||||
// Mixed output state (guarded by mu).
|
||||
ring []int16 // last prerollSamples of mixed audio
|
||||
active bool
|
||||
// paused freezes an ACTIVE take: nothing more is accumulated, but everything
|
||||
// captured so far is kept and the take still ends normally when the QSO is
|
||||
// logged. It is what lets an operator stop, play the recording back on the
|
||||
// air to the station they are working, and still have it saved with the QSO.
|
||||
paused bool
|
||||
acc []int16 // active QSO accumulation (seeded from ring on BeginQSO)
|
||||
}
|
||||
|
||||
@@ -110,11 +133,37 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
if prerollSec < 0 {
|
||||
prerollSec = 0
|
||||
}
|
||||
// Does the configured endpoint still EXIST?
|
||||
//
|
||||
// Endpoint ids are stored, and a DAX channel that is reconfigured, disabled
|
||||
// or removed comes back with a different id. The old one then opens without
|
||||
// complaint on some drivers and simply never streams — which is
|
||||
// indistinguishable from a quiet band until the recording turns out empty.
|
||||
// Checking the list takes milliseconds and answers it outright.
|
||||
if devs, derr := ListInputDevices(); derr == nil {
|
||||
known := func(id string) bool {
|
||||
for _, d := range devs {
|
||||
if d.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if fromDev != "" && !known(fromDev) {
|
||||
LogSink("recorder: the configured radio input no longer exists (%s) — re-select it in Settings → Audio", fromDev)
|
||||
AlertSink("The configured radio audio input no longer exists — re-select it in Settings")
|
||||
}
|
||||
if micDev != "" && micDev != fromDev && !known(micDev) {
|
||||
LogSink("recorder: the configured microphone no longer exists (%s) — re-select it in Settings → Audio", micDev)
|
||||
}
|
||||
}
|
||||
r.prerollSamples = prerollSec * sampleRate
|
||||
r.twoSrc = micDev != "" && micDev != fromDev
|
||||
r.stopCh = make(chan struct{})
|
||||
r.running = true
|
||||
r.ring, r.acc, r.active, r.bufA, r.bufB = nil, nil, false, nil, nil
|
||||
r.startedAt = time.Now()
|
||||
r.ring, r.acc, r.active, r.paused, r.bufA, r.bufB = nil, nil, false, false, nil, nil
|
||||
r.lastA, r.lastB, r.deadA, r.deadB = time.Time{}, time.Time{}, false, false
|
||||
stop := r.stopCh
|
||||
twoSrc := r.twoSrc
|
||||
r.mu.Unlock()
|
||||
@@ -124,27 +173,79 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer recoverGoroutine("recorder capture (radio)")
|
||||
_ = captureStream(fromDev, stop, func(chunk []byte) {
|
||||
// The error was discarded here. A device that cannot be opened — renamed,
|
||||
// unplugged, held by another program — then looked exactly like a device
|
||||
// that is merely quiet, and the recording came out empty with nothing in
|
||||
// the log to say why.
|
||||
if err := captureStream(fromDev, stop, func(chunk []byte) {
|
||||
s := bytesToInt16(chunk)
|
||||
r.srcMu.Lock()
|
||||
r.bufA = append(r.bufA, s...)
|
||||
r.lastA = time.Now()
|
||||
r.srcMu.Unlock()
|
||||
})
|
||||
}); err != nil {
|
||||
LogSink("recorder: capture from %q failed: %v", DeviceName(fromDev), err)
|
||||
}
|
||||
}()
|
||||
if twoSrc {
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer recoverGoroutine("recorder capture (mic)")
|
||||
_ = captureStream(micDev, stop, func(chunk []byte) {
|
||||
if err := captureStream(micDev, stop, func(chunk []byte) {
|
||||
s := bytesToInt16(chunk)
|
||||
r.srcMu.Lock()
|
||||
r.bufB = append(r.bufB, s...)
|
||||
r.lastB = time.Now()
|
||||
r.srcMu.Unlock()
|
||||
})
|
||||
}); err != nil {
|
||||
LogSink("recorder: capture from %q failed: %v", DeviceName(micDev), err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Name the devices being recorded FROM, once, at the start.
|
||||
//
|
||||
// A station with two radios has two sets of endpoints, and the recorder will
|
||||
// happily capture the one that is not being listened to: the result is a
|
||||
// file full of hiss with no relation to what the operator hears, and nothing
|
||||
// anywhere said which receiver it came from.
|
||||
if twoSrc {
|
||||
LogSink("recorder: capturing %q + %q", DeviceName(fromDev), DeviceName(micDev))
|
||||
} else {
|
||||
LogSink("recorder: capturing %q", DeviceName(fromDev))
|
||||
}
|
||||
|
||||
// Watchdog. A WASAPI device can open cleanly and then produce nothing at
|
||||
// all — a DAX channel with no stream behind it does exactly that, and it is
|
||||
// indistinguishable from silence until the recording turns out to be empty
|
||||
// at the end of a QSO. Say it once, three seconds in, while there is still
|
||||
// time to fix the setup.
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer recoverGoroutine("recorder watchdog")
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
r.srcMu.Lock()
|
||||
aQuiet, bQuiet := r.lastA.IsZero(), r.lastB.IsZero()
|
||||
r.srcMu.Unlock()
|
||||
if aQuiet {
|
||||
LogSink("recorder: no audio at all from %q after 3 s — the device opened but nothing is streaming", DeviceName(fromDev))
|
||||
AlertSink("No audio from %s — nothing is being recorded", DeviceName(fromDev))
|
||||
}
|
||||
// The mic is only worth a warning when the RADIO is silent too. On CW the
|
||||
// mic channel legitimately delivers nothing, and the mixer has already
|
||||
// said "recording the radio alone" — repeating it as an alarm made the log
|
||||
// read as if something were broken while the recording was going fine.
|
||||
if twoSrc && bQuiet && aQuiet {
|
||||
LogSink("recorder: no audio at all from %q either", DeviceName(micDev))
|
||||
}
|
||||
}()
|
||||
|
||||
// Mixer goroutine.
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
@@ -166,15 +267,79 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
|
||||
// mixTick drains the source queues, mixes what's available, and appends to the
|
||||
// ring + active accumulation.
|
||||
// deadSourceAfter is how long a source may deliver nothing before the recorder
|
||||
// carries on without it. Long enough not to trip on a scheduling hiccup, short
|
||||
// enough that almost nothing is lost from the source that IS working.
|
||||
const deadSourceAfter = 1500 * time.Millisecond
|
||||
|
||||
func (r *Recorder) mixTick() {
|
||||
r.srcMu.Lock()
|
||||
var mixed []int16
|
||||
if r.twoSrc {
|
||||
// A source that has been silent for a while is treated as ABSENT and the
|
||||
// other one is recorded alone.
|
||||
//
|
||||
// Two sources used to mean min(len(A), len(B)) samples: if one device
|
||||
// delivered nothing, NOTHING was recorded, and the drift guard below
|
||||
// then threw the live source away a second at a time. That is exactly
|
||||
// what happened on a Flex in CW — DAX Mic delivers nothing when the mic
|
||||
// path is not running — and the operator got "recording was empty" after
|
||||
// a whole QSO. Half a recording is worth having; silence is not.
|
||||
now := time.Now()
|
||||
// A source is dry when it has been quiet for too long — and a source that
|
||||
// has NEVER delivered is measured from when capture started, because it
|
||||
// has no last-delivery time of its own.
|
||||
//
|
||||
// The first version required a source to have spoken at least once. That
|
||||
// covers a device that stops, but not the one that actually happens: a
|
||||
// DAX Mic channel that is simply switched off never delivers a single
|
||||
// sample, so it stayed "not yet dry" forever and took the whole recording
|
||||
// down with it. Three empty CW recordings, with the radio audio streaming
|
||||
// perfectly the entire time.
|
||||
dry := func(last time.Time) bool {
|
||||
if last.IsZero() {
|
||||
return now.Sub(r.startedAt) > deadSourceAfter
|
||||
}
|
||||
return now.Sub(last) > deadSourceAfter
|
||||
}
|
||||
aDry, bDry := dry(r.lastA), dry(r.lastB)
|
||||
if bDry && !aDry && len(r.bufA) > 0 {
|
||||
if !r.deadB {
|
||||
r.deadB = true
|
||||
LogSink("recorder: the second audio source is silent — recording the radio alone")
|
||||
}
|
||||
mixed = make([]int16, len(r.bufA))
|
||||
for i, v := range r.bufA {
|
||||
mixed[i] = scaleSample(v, r.gainA)
|
||||
}
|
||||
r.bufA = r.bufA[:0]
|
||||
r.bufB = r.bufB[:0]
|
||||
r.srcMu.Unlock()
|
||||
r.store(mixed)
|
||||
return
|
||||
}
|
||||
if aDry && !bDry && len(r.bufB) > 0 {
|
||||
if !r.deadA {
|
||||
r.deadA = true
|
||||
LogSink("recorder: the radio audio source is silent — recording the microphone alone")
|
||||
}
|
||||
mixed = make([]int16, len(r.bufB))
|
||||
for i, v := range r.bufB {
|
||||
mixed[i] = scaleSample(v, r.gainB)
|
||||
}
|
||||
r.bufA = r.bufA[:0]
|
||||
r.bufB = r.bufB[:0]
|
||||
r.srcMu.Unlock()
|
||||
r.store(mixed)
|
||||
return
|
||||
}
|
||||
n := len(r.bufA)
|
||||
if len(r.bufB) < n {
|
||||
n = len(r.bufB)
|
||||
}
|
||||
if n > 0 {
|
||||
// Both alive again after one was written off.
|
||||
r.deadA, r.deadB = false, false
|
||||
mixed = make([]int16, n)
|
||||
for i := 0; i < n; i++ {
|
||||
mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB))
|
||||
@@ -182,12 +347,20 @@ func (r *Recorder) mixTick() {
|
||||
r.bufA = append(r.bufA[:0], r.bufA[n:]...)
|
||||
r.bufB = append(r.bufB[:0], r.bufB[n:]...)
|
||||
}
|
||||
// Drift guard: if the clocks diverge, drop the excess so the two
|
||||
// sources stay roughly aligned (≤1 s skew).
|
||||
if d := len(r.bufA) - len(r.bufB); d > sampleRate {
|
||||
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
|
||||
} else if d < -sampleRate {
|
||||
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
|
||||
// Drift guard: two sound cards run on their own clocks, so drop the
|
||||
// excess to keep them within a second of each other.
|
||||
//
|
||||
// ONLY while both are actually running. A starved source is not drift: it
|
||||
// made this guard throw away the radio audio a second at a time during
|
||||
// the grace period, so the opening of every recording was lost even
|
||||
// though it had been captured. Waiting costs nothing now — whatever is
|
||||
// buffered is written whole the moment the silent source is written off.
|
||||
if len(r.bufA) > 0 && len(r.bufB) > 0 {
|
||||
if d := len(r.bufA) - len(r.bufB); d > sampleRate {
|
||||
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
|
||||
} else if d < -sampleRate {
|
||||
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
|
||||
}
|
||||
}
|
||||
} else if len(r.bufA) > 0 {
|
||||
mixed = make([]int16, len(r.bufA))
|
||||
@@ -198,6 +371,12 @@ func (r *Recorder) mixTick() {
|
||||
}
|
||||
r.srcMu.Unlock()
|
||||
|
||||
r.store(mixed)
|
||||
}
|
||||
|
||||
// store appends mixed samples to the pre-roll ring and, when a take is running,
|
||||
// to the take itself.
|
||||
func (r *Recorder) store(mixed []int16) {
|
||||
if len(mixed) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -206,7 +385,7 @@ func (r *Recorder) mixTick() {
|
||||
if len(r.ring) > r.prerollSamples {
|
||||
r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
|
||||
}
|
||||
if r.active {
|
||||
if r.active && !r.paused {
|
||||
r.acc = append(r.acc, mixed...)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
@@ -221,7 +400,7 @@ func (r *Recorder) BeginQSO() {
|
||||
return
|
||||
}
|
||||
r.acc = append([]int16(nil), r.ring...)
|
||||
r.active = true
|
||||
r.active, r.paused = true, false
|
||||
}
|
||||
|
||||
// RestartQSO begins a fresh accumulation even if one is already active —
|
||||
@@ -236,7 +415,7 @@ func (r *Recorder) RestartQSO() {
|
||||
return
|
||||
}
|
||||
r.acc = append([]int16(nil), r.ring...)
|
||||
r.active = true
|
||||
r.active, r.paused = true, false
|
||||
}
|
||||
|
||||
// ResetQSOClock restarts the active accumulation from ZERO — discarding
|
||||
@@ -266,7 +445,7 @@ func (r *Recorder) TakeQSO() ([]byte, error) {
|
||||
return nil, fmt.Errorf("no active recording")
|
||||
}
|
||||
samples := r.acc
|
||||
r.acc, r.active = nil, false
|
||||
r.acc, r.active, r.paused = nil, false, false
|
||||
r.mu.Unlock()
|
||||
if len(samples) == 0 {
|
||||
return nil, fmt.Errorf("recording was empty")
|
||||
@@ -295,7 +474,7 @@ func (r *Recorder) SaveQSO(path string) error {
|
||||
// DiscardQSO drops the active accumulation without saving (callsign cleared).
|
||||
func (r *Recorder) DiscardQSO() {
|
||||
r.mu.Lock()
|
||||
r.acc, r.active = nil, false
|
||||
r.acc, r.active, r.paused = nil, false, false
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -313,7 +492,7 @@ func (r *Recorder) Stop() {
|
||||
close(stop)
|
||||
r.wg.Wait()
|
||||
r.mu.Lock()
|
||||
r.ring, r.acc, r.active = nil, nil, false
|
||||
r.ring, r.acc, r.active, r.paused = nil, nil, false, false
|
||||
r.mu.Unlock()
|
||||
r.srcMu.Lock()
|
||||
r.bufA, r.bufB = nil, nil
|
||||
@@ -346,3 +525,55 @@ func int16sToBytes(s []int16) []byte {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// PauseQSO freezes the active take without ending it: nothing more is recorded,
|
||||
// nothing is thrown away, and logging the QSO still writes the file.
|
||||
//
|
||||
// This exists for one situation, which is common enough on the bands to be
|
||||
// worth the state: you are working a station, you have been recording, and they
|
||||
// ask to hear it. You stop, you play it back to them on the air, and the
|
||||
// recording is still saved with the QSO afterwards.
|
||||
func (r *Recorder) PauseQSO() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.active {
|
||||
return false
|
||||
}
|
||||
r.paused = true
|
||||
return true
|
||||
}
|
||||
|
||||
// ResumeQSO continues an interrupted take, appending to what is already there.
|
||||
func (r *Recorder) ResumeQSO() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.active {
|
||||
return false
|
||||
}
|
||||
r.paused = false
|
||||
return true
|
||||
}
|
||||
|
||||
// Paused reports whether the active take is frozen.
|
||||
func (r *Recorder) Paused() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.active && r.paused
|
||||
}
|
||||
|
||||
// PeekQSO returns what has been captured so far WITHOUT ending the take.
|
||||
//
|
||||
// Unlike TakeQSO this keeps the audio, because the take is going to be played
|
||||
// back and then still saved with the QSO. The copy is deliberate: the caller
|
||||
// gets bytes it can hold while the recorder keeps appending to its own slice.
|
||||
func (r *Recorder) PeekQSO() ([]byte, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.active {
|
||||
return nil, fmt.Errorf("no active recording")
|
||||
}
|
||||
if len(r.acc) == 0 {
|
||||
return nil, fmt.Errorf("recording is empty")
|
||||
}
|
||||
return int16sToBytes(append([]int16(nil), r.acc...)), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A silent second source must not silence the recording.
|
||||
//
|
||||
// From a real session: a Flex with "From Radio" on DAX RX and "Recording mic"
|
||||
// on DAX Mic. In CW the mic path does not run, so DAX Mic delivered nothing —
|
||||
// and because the mixer took min(len(A), len(B)) samples, nothing at all was
|
||||
// recorded. The whole QSO ended in "recording was empty".
|
||||
func TestMixerSurvivesASilentSource(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
|
||||
// The radio has been talking; the mic has not spoken for well over the
|
||||
// grace period.
|
||||
r.bufA = make([]int16, 800)
|
||||
for i := range r.bufA {
|
||||
r.bufA[i] = 1000
|
||||
}
|
||||
r.lastA = time.Now()
|
||||
r.lastB = time.Now().Add(-5 * time.Second)
|
||||
|
||||
r.mixTick()
|
||||
|
||||
if len(r.acc) == 0 {
|
||||
t.Fatal("nothing was recorded — a silent mic must not stop the radio being captured")
|
||||
}
|
||||
if r.acc[0] != 1000 {
|
||||
t.Errorf("sample = %d, want 1000 — the live source must pass through unchanged", r.acc[0])
|
||||
}
|
||||
}
|
||||
|
||||
// While BOTH sources are alive the two are mixed, as before.
|
||||
func TestMixerStillMixesBothSources(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
r.bufA = []int16{100, 100, 100}
|
||||
r.bufB = []int16{50, 50, 50}
|
||||
now := time.Now()
|
||||
r.lastA, r.lastB = now, now
|
||||
|
||||
r.mixTick()
|
||||
|
||||
if len(r.acc) != 3 {
|
||||
t.Fatalf("recorded %d samples, want 3", len(r.acc))
|
||||
}
|
||||
if r.acc[0] != 150 {
|
||||
t.Errorf("sample = %d, want 150 — both sources should be summed", r.acc[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Just after capture starts, a source that has not delivered yet is NOT written
|
||||
// off: the first samples take a moment to arrive, and declaring the mic dead at
|
||||
// once would drop the opening of every recording.
|
||||
func TestMixerWaitsBrieflyAtStartup(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
r.startedAt = time.Now()
|
||||
r.bufA = []int16{100, 100}
|
||||
|
||||
r.mixTick()
|
||||
|
||||
if len(r.acc) != 0 {
|
||||
t.Errorf("recorded %d samples immediately — the second source deserves a moment to start", len(r.acc))
|
||||
}
|
||||
}
|
||||
|
||||
// A source that has NEVER delivered is written off once enough time has passed.
|
||||
//
|
||||
// This is the case that actually happens, and the one the first version missed:
|
||||
// a DAX Mic channel switched off delivers not one sample, so it had no
|
||||
// last-delivery time and stayed forever "not yet dry" — taking the whole
|
||||
// recording down with it while the radio audio streamed perfectly.
|
||||
func TestMixerWritesOffASourceThatNeverSpoke(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
r.startedAt = time.Now().Add(-5 * time.Second)
|
||||
r.bufA = []int16{700, 700, 700}
|
||||
r.lastA = time.Now()
|
||||
// lastB stays zero: the mic has never produced anything at all.
|
||||
|
||||
r.mixTick()
|
||||
|
||||
if len(r.acc) != 3 {
|
||||
t.Fatalf("recorded %d samples, want 3 — the radio was streaming the whole time", len(r.acc))
|
||||
}
|
||||
if r.acc[0] != 700 {
|
||||
t.Errorf("sample = %d, want 700", r.acc[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing captured during the grace period is thrown away.
|
||||
//
|
||||
// The drift guard exists for two sound cards running on their own clocks. A
|
||||
// STARVED source is not drift, and treating it as such discarded the radio
|
||||
// audio a second at a time while the mixer was still waiting for the mic — so
|
||||
// the opening of every CW recording was lost although it had been captured.
|
||||
func TestGracePeriodKeepsWhatWasCaptured(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
r.startedAt = time.Now()
|
||||
|
||||
// Three seconds of radio audio arrive while the mic says nothing at all.
|
||||
r.bufA = make([]int16, 3*sampleRate)
|
||||
for i := range r.bufA {
|
||||
r.bufA[i] = 500
|
||||
}
|
||||
r.lastA = time.Now()
|
||||
r.mixTick() // still inside the grace period: nothing is written yet…
|
||||
|
||||
if len(r.acc) != 0 {
|
||||
t.Fatalf("wrote %d samples before the mic was written off", len(r.acc))
|
||||
}
|
||||
if len(r.bufA) != 3*sampleRate {
|
||||
t.Fatalf("buffered audio was trimmed to %d samples — it must be kept, not dropped", len(r.bufA))
|
||||
}
|
||||
|
||||
// …and once the mic is written off, everything captured comes through.
|
||||
r.startedAt = time.Now().Add(-5 * time.Second)
|
||||
r.mixTick()
|
||||
if len(r.acc) != 3*sampleRate {
|
||||
t.Errorf("recorded %d samples, want the full %d captured during the wait", len(r.acc), 3*sampleRate)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// goertzel returns the magnitude of one frequency bin, so a test can ask "how
|
||||
// much energy is at 10 kHz" without pulling in an FFT.
|
||||
func goertzel(x []int16, fs, freq float64) float64 {
|
||||
w := 2 * math.Pi * freq / fs
|
||||
c := 2 * math.Cos(w)
|
||||
var s1, s2 float64
|
||||
for _, v := range x {
|
||||
s := float64(v) + c*s1 - s2
|
||||
s2, s1 = s1, s
|
||||
}
|
||||
return math.Hypot(s1-s2*math.Cos(w), s2*math.Sin(w))
|
||||
}
|
||||
|
||||
// Upsampling 16 kHz → 32 kHz must not create an audible mirror image.
|
||||
//
|
||||
// MP3 recordings came back with a hiss that is not on the air and is not in the
|
||||
// WAV of the same audio — "louder than the voice", from a station that hears the
|
||||
// voice well clear of the noise. The MP3 path is the only one that resamples,
|
||||
// and it did so by linear interpolation: every component at f is mirrored to
|
||||
// 16 kHz − f, which for broadband receiver noise means a second noise floor
|
||||
// spread across the top of the band.
|
||||
func TestUpsampleSuppressesTheImage(t *testing.T) {
|
||||
const fs = 16000.0
|
||||
const tone = 6000.0 // its image lands at 16000 − 6000 = 10 kHz
|
||||
|
||||
in := make([]int16, 4096)
|
||||
for i := range in {
|
||||
in[i] = int16(12000 * math.Sin(2*math.Pi*tone*float64(i)/fs))
|
||||
}
|
||||
|
||||
out := upsample2(in)
|
||||
if len(out) != 2*len(in) {
|
||||
t.Fatalf("upsample2 returned %d samples for %d", len(out), len(in))
|
||||
}
|
||||
|
||||
wanted := goertzel(out, 2*fs, tone)
|
||||
image := goertzel(out, 2*fs, 2*fs-tone-16000) // = 10 kHz
|
||||
if wanted == 0 {
|
||||
t.Fatal("the tone itself did not survive upsampling")
|
||||
}
|
||||
db := 20 * math.Log10(image/wanted)
|
||||
t.Logf("image at 10 kHz is %.1f dB below the 6 kHz tone", db)
|
||||
if db > -35 {
|
||||
t.Errorf("image only %.1f dB down — it is audible as added hiss; want at least 35 dB", db)
|
||||
}
|
||||
}
|
||||
|
||||
// The filter must not change the level, or every existing recording would come
|
||||
// back quieter (or clipped) after the fix — a second surprise on top of the one
|
||||
// being repaired.
|
||||
func TestUpsampleKeepsTheLevel(t *testing.T) {
|
||||
const fs = 16000.0
|
||||
in := make([]int16, 4096)
|
||||
for i := range in {
|
||||
in[i] = int16(10000 * math.Sin(2*math.Pi*1000*float64(i)/fs))
|
||||
}
|
||||
rms := func(x []int16) float64 {
|
||||
var acc float64
|
||||
for _, v := range x {
|
||||
acc += float64(v) * float64(v)
|
||||
}
|
||||
return math.Sqrt(acc / float64(len(x)))
|
||||
}
|
||||
// Skip the filter's warm-up and tail, where the window is only partly fed.
|
||||
out := upsample2(in)
|
||||
got, want := rms(out[200:len(out)-200]), rms(in[100:len(in)-100])
|
||||
ratio := got / want
|
||||
t.Logf("level after upsampling: ×%.3f", ratio)
|
||||
if ratio < 0.9 || ratio > 1.1 {
|
||||
t.Errorf("level changed by ×%.3f — want within 10%%", ratio)
|
||||
}
|
||||
}
|
||||
@@ -33,13 +33,13 @@ func writeWAV(path string, pcm []byte) error {
|
||||
put(uint32(36 + dataLen))
|
||||
f.WriteString("WAVE")
|
||||
f.WriteString("fmt ")
|
||||
put(uint32(16)) // PCM fmt chunk size
|
||||
put(uint16(1)) // WAVE_FORMAT_PCM
|
||||
put(uint16(channels)) //
|
||||
put(uint32(sampleRate)) //
|
||||
put(uint32(bytesPerSec)) // byte rate
|
||||
put(uint16(blockAlign)) //
|
||||
put(uint16(bitsPerSample)) //
|
||||
put(uint32(16)) // PCM fmt chunk size
|
||||
put(uint16(1)) // WAVE_FORMAT_PCM
|
||||
put(uint16(channels)) //
|
||||
put(uint32(sampleRate)) //
|
||||
put(uint32(bytesPerSec)) // byte rate
|
||||
put(uint16(blockAlign)) //
|
||||
put(uint16(bitsPerSample)) //
|
||||
f.WriteString("data")
|
||||
put(uint32(dataLen))
|
||||
_, err = f.Write(pcm)
|
||||
|
||||
+97
-14
@@ -75,10 +75,22 @@ type Def struct {
|
||||
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
||||
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
||||
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
||||
// NOTE: there is no "one reference per QSO" switch, and there was never any
|
||||
// point in one. A QSO ALWAYS yields every reference its field holds — an n-fer
|
||||
// POTA activation ("US-6544,US-0680"), a VUCC contact on a grid line. The old
|
||||
// `Multi` flag was read by nothing; its checkbox changed nothing.
|
||||
// OneRefPerQSO refuses an AMBIGUOUS match rather than guessing.
|
||||
//
|
||||
// This is not the old `Multi` flag, which was removed for good reason: a QSO
|
||||
// always yields every reference its FIELD holds — an n-fer POTA activation
|
||||
// ("US-6544,US-0680"), a VUCC contact on a grid line. That is several
|
||||
// references, correctly.
|
||||
//
|
||||
// The case here is the opposite. The field holds ONE value and two entries in
|
||||
// the reference list carry it: two German DOKs are both called "Gießen",
|
||||
// because two clubs share the town. The matcher is not wrong, the data is
|
||||
// ambiguous — and picking one would write a reference into the log that may
|
||||
// well be the other. So on an award where a QSO can only ever count for one
|
||||
// reference, an ambiguous match assigns NONE and the contact appears under
|
||||
// "missing references", where the operator picks. Their choice then sticks:
|
||||
// a manual reference overrides the matcher.
|
||||
OneRefPerQSO bool `json:"one_ref_per_qso,omitempty"`
|
||||
|
||||
// OrRules are ordered FALLBACK searches for the primary one above: they are
|
||||
// tried IN ORDER and only while nothing has matched yet — the first rule that
|
||||
@@ -334,13 +346,19 @@ type BandCount struct {
|
||||
|
||||
// Ref is one reference's status within an award.
|
||||
type Ref struct {
|
||||
Ref string `json:"ref"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
SubGrp string `json:"subgrp,omitempty"`
|
||||
Worked bool `json:"worked"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
Validated bool `json:"validated"`
|
||||
Ref string `json:"ref"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
SubGrp string `json:"subgrp,omitempty"`
|
||||
Worked bool `json:"worked"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
Validated bool `json:"validated"`
|
||||
// Modes / ConfirmedModes are CLASSES, not ADIF modes: "CW", "PHONE",
|
||||
// "DIGI". An operator asking "which entities have I worked on CW but not
|
||||
// confirmed" does not care whether it was FT8 or RTTY on the digital side,
|
||||
// and a list of twenty mode names would not answer the question they asked.
|
||||
Modes []string `json:"modes"`
|
||||
ConfirmedModes []string `json:"confirmed_modes"`
|
||||
Bands []string `json:"bands"`
|
||||
ConfirmedBands []string `json:"confirmed_bands"`
|
||||
ValidatedBands []string `json:"validated_bands"`
|
||||
@@ -360,11 +378,33 @@ type Result struct {
|
||||
Error string `json:"error,omitempty"` // e.g. bad regexp pattern
|
||||
}
|
||||
|
||||
// ModeClass sorts an ADIF mode into the three classes an operator thinks in:
|
||||
// CW, PHONE, DIGI. Anything unrecognised returns "" and is simply not counted
|
||||
// under any class — better than inventing one, since these classes drive a
|
||||
// filter that decides what the operator is shown.
|
||||
func ModeClass(mode string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "CW", "CWR":
|
||||
return "CW"
|
||||
case "SSB", "USB", "LSB", "AM", "FM", "DV", "PHONE", "DIGITALVOICE":
|
||||
return "PHONE"
|
||||
case "":
|
||||
return ""
|
||||
}
|
||||
// Everything else that a logbook actually carries is a data mode: FT8, FT4,
|
||||
// RTTY, PSK31, JS8, Q65, MSK144, OLIVIA, VARA… Listing them exhaustively
|
||||
// would mean a new mode silently vanishing from the filter the day it
|
||||
// appears, which is worse than treating an unknown data mode as data.
|
||||
return "DIGI"
|
||||
}
|
||||
|
||||
// NameResolver optionally maps a (field, ref) pair to a human name. May be nil.
|
||||
type NameResolver func(field, ref string) string
|
||||
|
||||
type refAgg struct {
|
||||
bands map[string]struct{}
|
||||
modes map[string]struct{}
|
||||
confirmedModes map[string]struct{}
|
||||
confirmedBands map[string]struct{}
|
||||
validatedBands map[string]struct{}
|
||||
anyConfirmed bool
|
||||
@@ -481,22 +521,33 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
||||
continue
|
||||
}
|
||||
band := strings.ToLower(strings.TrimSpace(q.Band))
|
||||
modeClass := ModeClass(q.Mode)
|
||||
isConf := confirmed(q, d.Confirm)
|
||||
isVal := confirmed(q, d.Validate)
|
||||
for _, ref := range refs {
|
||||
a := agg[i][ref]
|
||||
if a == nil {
|
||||
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}, validatedBands: map[string]struct{}{}}
|
||||
a = &refAgg{
|
||||
bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{},
|
||||
validatedBands: map[string]struct{}{},
|
||||
modes: map[string]struct{}{}, confirmedModes: map[string]struct{}{},
|
||||
}
|
||||
agg[i][ref] = a
|
||||
}
|
||||
if band != "" {
|
||||
a.bands[band] = struct{}{}
|
||||
}
|
||||
if modeClass != "" {
|
||||
a.modes[modeClass] = struct{}{}
|
||||
}
|
||||
if isConf {
|
||||
a.anyConfirmed = true
|
||||
if band != "" {
|
||||
a.confirmedBands[band] = struct{}{}
|
||||
}
|
||||
if modeClass != "" {
|
||||
a.confirmedModes[modeClass] = struct{}{}
|
||||
}
|
||||
}
|
||||
if isVal {
|
||||
a.anyValidated = true
|
||||
@@ -525,6 +576,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
||||
r.Validated++
|
||||
}
|
||||
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
|
||||
Modes: setToSorted(a.modes), ConfirmedModes: setToSorted(a.confirmedModes),
|
||||
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands), ValidatedBands: setToSorted(a.validatedBands)}
|
||||
labelRef(&rf, d, ref, rl, hasList, nameOf)
|
||||
r.Refs = append(r.Refs, rf)
|
||||
@@ -819,7 +871,15 @@ type Explanation struct {
|
||||
RefCount int `json:"ref_count"` // size of that list
|
||||
Steps []Step `json:"steps"`
|
||||
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
|
||||
Result []string `json:"result"` // what the QSO finally counts for
|
||||
// Superseded is what the matcher had found before the operator's choice
|
||||
// replaced it — shown in the trace so a correction is explainable rather
|
||||
// than mysterious.
|
||||
Superseded []string `json:"superseded,omitempty"`
|
||||
// Ambiguous lists the references that matched when the award allows only one
|
||||
// — none of them was kept. Shown so the operator sees WHICH ones to choose
|
||||
// between rather than an unexplained blank.
|
||||
Ambiguous []string `json:"ambiguous,omitempty"`
|
||||
Result []string `json:"result"` // what the QSO finally counts for
|
||||
}
|
||||
|
||||
// Explain runs the matcher on a single QSO and reports what it did. It goes
|
||||
@@ -937,8 +997,31 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
|
||||
if ex != nil {
|
||||
ex.Manual = manual
|
||||
}
|
||||
found = append(found, manual...)
|
||||
// An operator's choice REPLACES what the matcher found. It does not join it.
|
||||
//
|
||||
// Adding to it was the behaviour, and it made a correction impossible: a
|
||||
// German address matching two or three DOKs kept them all, so deleting the
|
||||
// wrong ones and assigning the right one lasted exactly until the next
|
||||
// recompute, which put them straight back. The operator was arguing with
|
||||
// the matcher and could not win.
|
||||
//
|
||||
// One manual entry is stored per award (setOverrideRef drops the previous
|
||||
// one), so this is an override in the storage as well as in the name.
|
||||
if len(manual) > 0 {
|
||||
if ex != nil {
|
||||
ex.Superseded = dedupe(found)
|
||||
}
|
||||
found = manual
|
||||
}
|
||||
out := dedupe(found)
|
||||
// Ambiguity is not a result. See OneRefPerQSO.
|
||||
if d.OneRefPerQSO && len(manual) == 0 && len(out) > 1 {
|
||||
if ex != nil {
|
||||
ex.Ambiguous = out
|
||||
ex.Result = []string{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if ex != nil {
|
||||
ex.Result = out
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
package award
|
||||
|
||||
import "testing"
|
||||
|
||||
// The three classes an operator thinks in — the ones the awards filter offers.
|
||||
//
|
||||
// "Which entities have I worked on CW but not confirmed" is the question this
|
||||
// serves, so the classes must match how the question is asked: CW, phone, data.
|
||||
// Which data mode it was does not enter into it.
|
||||
func TestModeClass(t *testing.T) {
|
||||
for _, c := range []struct{ mode, want string }{
|
||||
{"CW", "CW"}, {"cw", "CW"}, {" CW ", "CW"}, {"CWR", "CW"},
|
||||
{"SSB", "PHONE"}, {"USB", "PHONE"}, {"LSB", "PHONE"},
|
||||
{"AM", "PHONE"}, {"FM", "PHONE"}, {"DV", "PHONE"},
|
||||
{"FT8", "DIGI"}, {"FT4", "DIGI"}, {"RTTY", "DIGI"}, {"PSK31", "DIGI"},
|
||||
{"JS8", "DIGI"}, {"Q65", "DIGI"}, {"OLIVIA", "DIGI"},
|
||||
// A mode nobody has heard of yet is data, not nothing: the alternative
|
||||
// is a new digital mode silently vanishing from the filter the day it
|
||||
// appears.
|
||||
{"SOMETHINGNEW", "DIGI"},
|
||||
// No mode at all is not a class, and must not be counted as one.
|
||||
{"", ""}, {" ", ""},
|
||||
} {
|
||||
if got := ModeClass(c.mode); got != c.want {
|
||||
t.Errorf("ModeClass(%q) = %q, want %q", c.mode, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package award
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// An operator's correction must survive a recompute.
|
||||
//
|
||||
// Reported on the DARC DOK award: a German address matches two or three DOKs,
|
||||
// the operator deletes the wrong ones and assigns the right one, and the next
|
||||
// recompute puts the wrong ones straight back. The manual reference was being
|
||||
// ADDED to what the matcher found instead of replacing it, so the correction
|
||||
// could never win.
|
||||
func TestManualReferenceReplacesTheMatch(t *testing.T) {
|
||||
// An award that reads the address and matches a reference's description —
|
||||
// the shape the DOK award uses, and the shape that over-matches.
|
||||
def := Def{
|
||||
Code: "DLD", Name: "DARC DOK", Field: "address",
|
||||
MatchBy: "description", Type: "QSOFIELDS", Valid: true,
|
||||
}
|
||||
metas := []RefMeta{
|
||||
{Code: "A01", Name: "Berlin", Valid: true},
|
||||
{Code: "B05", Name: "Berlin Mitte", Valid: true},
|
||||
}
|
||||
// An address that legitimately matches both.
|
||||
q := &qso.QSO{
|
||||
Callsign: "DL2FDM",
|
||||
Address: "Berlin Mitte, 10115 Berlin",
|
||||
}
|
||||
|
||||
auto := Explain(def, metas, q)
|
||||
if len(auto.Result) < 2 {
|
||||
t.Fatalf("the test needs an over-matching case; got %v", auto.Result)
|
||||
}
|
||||
|
||||
// The operator picks the right one.
|
||||
q.Extras = map[string]string{ManualRefsKey: "DLD@B05"}
|
||||
fixed := Explain(def, metas, q)
|
||||
|
||||
if got := strings.Join(fixed.Result, ","); got != "B05" {
|
||||
t.Errorf("after the correction the QSO counts for %q — want B05 alone", got)
|
||||
}
|
||||
if len(fixed.Superseded) == 0 {
|
||||
t.Error("the trace does not say what the correction replaced")
|
||||
}
|
||||
|
||||
// A QSO with no correction still gets everything the matcher found: the
|
||||
// override must not disable matching for everyone else.
|
||||
q2 := &qso.QSO{Callsign: "DL3XYZ", Address: "Berlin Mitte, 10115 Berlin"}
|
||||
if len(Explain(def, metas, q2).Result) < 2 {
|
||||
t.Error("an untouched QSO lost its automatic references")
|
||||
}
|
||||
}
|
||||
|
||||
// On an award where a QSO can only count for ONE reference, an ambiguous match
|
||||
// assigns none.
|
||||
//
|
||||
// Two DARC DOKs are called "Gießen" because two clubs share the town. Exact
|
||||
// matching finds both, and both are right as far as the matcher can tell.
|
||||
// Picking one would write a reference into the log that may well be the other —
|
||||
// so the contact is left for the operator, who now has the last word.
|
||||
func TestAmbiguousMatchIsRefusedWhenOnlyOneReferenceIsAllowed(t *testing.T) {
|
||||
def := Def{
|
||||
Code: "DLD", Name: "DARC DOK", Field: "qth",
|
||||
MatchBy: "description", ExactMatch: true, Type: "QSOFIELDS", Valid: true,
|
||||
OneRefPerQSO: true,
|
||||
}
|
||||
metas := []RefMeta{
|
||||
{Code: "F07", Name: "Gießen", Valid: true},
|
||||
{Code: "F61", Name: "Gießen", Valid: true},
|
||||
{Code: "B11", Name: "Kassel", Valid: true},
|
||||
}
|
||||
|
||||
amb := Explain(def, metas, &qso.QSO{Callsign: "DL2FDM", QTH: "Gießen"})
|
||||
if len(amb.Result) != 0 {
|
||||
t.Errorf("an ambiguous match produced %v — it should produce nothing", amb.Result)
|
||||
}
|
||||
if len(amb.Ambiguous) != 2 {
|
||||
t.Errorf("the trace lists %v — it should name both candidates", amb.Ambiguous)
|
||||
}
|
||||
|
||||
// An unambiguous one is untouched: this must not make the award stricter in
|
||||
// general, only where the data genuinely does not decide.
|
||||
ok := Explain(def, metas, &qso.QSO{Callsign: "DL3XYZ", QTH: "Kassel"})
|
||||
if len(ok.Result) != 1 || ok.Result[0] != "B11" {
|
||||
t.Errorf("an unambiguous QSO gave %v — want B11", ok.Result)
|
||||
}
|
||||
|
||||
// And the operator's choice still wins over the ambiguity.
|
||||
fixed := Explain(def, metas, &qso.QSO{
|
||||
Callsign: "DL2FDM", QTH: "Gießen",
|
||||
Extras: map[string]string{ManualRefsKey: "DLD@F61"},
|
||||
})
|
||||
if len(fixed.Result) != 1 || fixed.Result[0] != "F61" {
|
||||
t.Errorf("after the operator picked F61 the QSO counts for %v", fixed.Result)
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func DefaultFolder(dataDir string) string {
|
||||
// statement (no torn-copy window while the app keeps writing), and compacts
|
||||
// the destination as a bonus. It replaces the old "checkpoint + raw io.Copy",
|
||||
// which could capture a half-written page during a concurrent write.
|
||||
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool) (string, error) {
|
||||
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool, prefix string) (string, error) {
|
||||
if dbConn == nil {
|
||||
return "", fmt.Errorf("nil db connection")
|
||||
}
|
||||
@@ -54,12 +54,15 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
|
||||
if folder == "" {
|
||||
return "", fmt.Errorf("backup folder not set")
|
||||
}
|
||||
if prefix == "" {
|
||||
prefix = "opslog"
|
||||
}
|
||||
if err := os.MkdirAll(folder, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create backup folder: %w", err)
|
||||
}
|
||||
|
||||
stamp := time.Now().Format("2006-01-02")
|
||||
base := fmt.Sprintf("opslog-%s", stamp)
|
||||
base := fmt.Sprintf("%s-%s", prefix, stamp)
|
||||
|
||||
// VACUUM INTO requires a non-existent target → use a temp file, then
|
||||
// move/zip it into place.
|
||||
@@ -92,7 +95,7 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
|
||||
}
|
||||
}
|
||||
|
||||
if err := rotate(folder, rotation); err != nil {
|
||||
if err := rotateMatch(folder, rotation, prefix+"-", ".db", ".db.zip"); err != nil {
|
||||
// Rotation errors are non-fatal — the backup itself succeeded.
|
||||
return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath)
|
||||
}
|
||||
@@ -203,12 +206,6 @@ func copyZipped(src, dst, innerName string) error {
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
// rotate keeps the most recent `keep` SQLite backups (opslog-*.db /
|
||||
// opslog-*.db.zip) and deletes the rest.
|
||||
func rotate(folder string, keep int) error {
|
||||
return rotateMatch(folder, keep, "opslog-", ".db", ".db.zip")
|
||||
}
|
||||
|
||||
// rotateMatch keeps the most recent `keep` files in folder whose name has the
|
||||
// given prefix and one of the given suffixes, deleting older ones. Only matching
|
||||
// files are touched — never unrelated user files in the same folder. The suffix
|
||||
|
||||
@@ -869,3 +869,22 @@ func (m *Manager) YaesuDo(fn func(YaesuController) error) error {
|
||||
return fn(yc)
|
||||
})
|
||||
}
|
||||
|
||||
// KenwoodController is the Kenwood/Elecraft CW-over-CAT capability (the KY keyer),
|
||||
// so a K3 can key CW through its single CAT link instead of a second COM port.
|
||||
type KenwoodController interface {
|
||||
SendCW(string) error
|
||||
StopCW() error
|
||||
SetKeySpeed(int) error
|
||||
}
|
||||
|
||||
// KenwoodDo dispatches a Kenwood control onto the CAT goroutine.
|
||||
func (m *Manager) KenwoodDo(fn func(KenwoodController) error) error {
|
||||
return m.exec(func(b Backend) error {
|
||||
kc, ok := b.(KenwoodController)
|
||||
if !ok {
|
||||
return fmt.Errorf("active CAT backend is not a Kenwood/Elecraft")
|
||||
}
|
||||
return fn(kc)
|
||||
})
|
||||
}
|
||||
|
||||
+17
-4
@@ -160,16 +160,29 @@ func FreqToBCD(hz int64) []byte {
|
||||
}
|
||||
|
||||
// BCDToFreq decodes Icom little-endian BCD frequency bytes back to Hz.
|
||||
func BCDToFreq(b []byte) int64 {
|
||||
//
|
||||
// It returns ok=false when a nibble is not a decimal digit. That check is the
|
||||
// point of the function: a CI-V byte stream that has lost frame sync — a reply
|
||||
// read at the wrong offset, a collision with the scope stream — still decodes
|
||||
// into a number if 0x0A..0x0F are quietly treated as 10..15. An operator on a
|
||||
// 2 m FM channel then saw entries like 16445550000 Hz appear in the status bar
|
||||
// between good readings (F4JND, 2026-07-30). Garbage that LOOKS like a
|
||||
// frequency is worse than a refused frame: it reaches the display, the band
|
||||
// slots, and eventually the log.
|
||||
func BCDToFreq(b []byte) (int64, bool) {
|
||||
var hz int64
|
||||
mult := int64(1)
|
||||
for i := 0; i < len(b) && i < 5; i++ {
|
||||
hz += int64(b[i]&0x0F) * mult
|
||||
lo, hi := b[i]&0x0F, b[i]>>4
|
||||
if lo > 9 || hi > 9 {
|
||||
return 0, false
|
||||
}
|
||||
hz += int64(lo) * mult
|
||||
mult *= 10
|
||||
hz += int64(b[i]>>4) * mult
|
||||
hz += int64(hi) * mult
|
||||
mult *= 10
|
||||
}
|
||||
return hz
|
||||
return hz, true
|
||||
}
|
||||
|
||||
// LevelToBCD encodes a 0-255 level as the 2 big-endian BCD bytes Icom's
|
||||
|
||||
@@ -12,8 +12,9 @@ func TestFreqBCDRoundTrip(t *testing.T) {
|
||||
if len(b) != 5 {
|
||||
t.Fatalf("FreqToBCD(%d) len=%d, want 5", hz, len(b))
|
||||
}
|
||||
if got := BCDToFreq(b); got != hz {
|
||||
t.Errorf("round trip %d → % X → %d", hz, b, got)
|
||||
got, ok := BCDToFreq(b)
|
||||
if !ok || got != hz {
|
||||
t.Errorf("round trip %d → % X → %d (ok=%v)", hz, b, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,8 +50,8 @@ func TestScanSingleFreqResponse(t *testing.T) {
|
||||
if f.From != 0x98 || f.To != AddrController || f.Cmd != CmdReadFreq {
|
||||
t.Errorf("addrs/cmd wrong: %+v", f)
|
||||
}
|
||||
if hz := BCDToFreq(f.Data); hz != 14250000 {
|
||||
t.Errorf("decoded freq %d, want 14250000", hz)
|
||||
if hz, ok := BCDToFreq(f.Data); !ok || hz != 14250000 {
|
||||
t.Errorf("decoded freq %d (ok=%v), want 14250000", hz, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,3 +164,43 @@ func TestModelNameAddresses(t *testing.T) {
|
||||
t.Errorf("ModelName(0x42) = %q, want the hex fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A frame that is not decimal BCD is refused rather than decoded.
|
||||
//
|
||||
// From an operator's log (F4JND, 2026-07-30): a rig sitting on 145.550 MHz FM
|
||||
// produced status lines reading 16445550000, 8364550000, 5817408364 and 12640 Hz
|
||||
// between good readings. CI-V had lost frame sync — a reply read at the wrong
|
||||
// offset, alongside PTT timeouts and a scope stream the rig was rejecting — and
|
||||
// the decoder treated the nibbles 0x0A..0x0F as the digits 10..15, turning noise
|
||||
// into a plausible-looking number.
|
||||
//
|
||||
// Garbage that looks like a frequency is worse than a refused frame: it reaches
|
||||
// the status bar, the band slots and the log, and it is what an operator then
|
||||
// has to disprove.
|
||||
func TestBCDToFreqRejectsNonDecimal(t *testing.T) {
|
||||
// Valid: 145.550 MHz, encoded by the same package rather than by hand — my
|
||||
// hand-written fixture was byte-reversed and this caught it.
|
||||
good := FreqToBCD(145550000)
|
||||
if hz, ok := BCDToFreq(good); !ok || hz != 145550000 {
|
||||
t.Errorf("valid BCD % X decoded as %d (ok=%v), want 145550000", good, hz, ok)
|
||||
}
|
||||
|
||||
// Every nibble value above 9 must be refused, in either half of the byte.
|
||||
bad := [][]byte{
|
||||
{0x0A, 0x00, 0x55, 0x45, 0x01}, // low nibble
|
||||
{0x00, 0x00, 0xF5, 0x45, 0x01}, // high nibble
|
||||
{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, // an idle line read as data
|
||||
{0x00, 0x00, 0x00, 0x0B, 0x00},
|
||||
}
|
||||
for _, b := range bad {
|
||||
if hz, ok := BCDToFreq(b); ok {
|
||||
t.Errorf("% X was accepted as %d Hz — it is not decimal BCD", b, hz)
|
||||
}
|
||||
}
|
||||
|
||||
// A short frame still decodes what it holds: the caller decides whether a
|
||||
// partial read is usable, and refusing it here would break the 4-byte forms.
|
||||
if _, ok := BCDToFreq([]byte{0x00, 0x50}); !ok {
|
||||
t.Error("a short but valid BCD frame must still decode")
|
||||
}
|
||||
}
|
||||
|
||||
+105
-27
@@ -35,7 +35,12 @@ type Flex struct {
|
||||
model string
|
||||
gotHandle bool
|
||||
|
||||
slices map[int]*flexSlice
|
||||
slices map[int]*flexSlice
|
||||
// pinnedSlice is the slice the operator chose IN OPSLOG (-1 = none). It
|
||||
// overrides the radio's own "active" flag, and only an OpsLog click changes
|
||||
// it — activating a slice on the radio's front panel or in SmartSDR does
|
||||
// not, by design (see mainSliceLocked).
|
||||
pinnedSlice int
|
||||
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
||||
amp flexAmp // external amplifier (PowerGenius XL) state
|
||||
micProfiles []string // available mic profiles (SmartSDR "profile mic list")
|
||||
@@ -56,10 +61,12 @@ type Flex struct {
|
||||
|
||||
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
||||
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
|
||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
||||
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
||||
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||
|
||||
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
||||
@@ -180,9 +187,10 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
||||
return &Flex{
|
||||
host: strings.TrimSpace(host), port: port,
|
||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||
pinnedSlice: -1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +316,12 @@ func (f *Flex) send(cmd string) int {
|
||||
debugLog.Printf("Flex: send %q failed: %v", cmd, err)
|
||||
return 0
|
||||
}
|
||||
debugLog.Printf("Flex: → %s", cmd)
|
||||
// Meter subscriptions are pure bookkeeping and a Flex announces meters one
|
||||
// status line at a time — tracing each "sub meter N" wrote dozens of lines
|
||||
// at every connect.
|
||||
if !strings.HasPrefix(cmd, "sub meter ") {
|
||||
debugLog.Printf("Flex: → %s", cmd)
|
||||
}
|
||||
return seq
|
||||
}
|
||||
|
||||
@@ -327,11 +340,17 @@ func (f *Flex) reader(conn net.Conn) {
|
||||
if idx, err := strconv.Atoi(mm[1]); err == nil {
|
||||
f.mu.Lock()
|
||||
call := f.spotCall[idx]
|
||||
mode := f.spotMode[idx]
|
||||
handler := f.OnSpotClick
|
||||
f.mu.Unlock()
|
||||
if call != "" && handler != nil {
|
||||
debugLog.Printf("Flex: spot %d triggered → %s", idx, call)
|
||||
debugLog.Printf("Flex: spot %d triggered → %s (mode %s)", idx, call, mode)
|
||||
go handler(call, 0)
|
||||
// SmartSDR tunes the spot's frequency on click but does NOT apply
|
||||
// its mode=, so set the slice mode ourselves from what we spotted.
|
||||
if mode != "" {
|
||||
go func(m string) { _ = f.SetMode(m) }(mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -368,12 +387,15 @@ func (f *Flex) reader(conn net.Conn) {
|
||||
// pair it with the callsign we stashed under this seq.
|
||||
f.mu.Lock()
|
||||
call, pending := f.pendingSpot[seq]
|
||||
spotMode := f.pendingSpotMode[seq]
|
||||
if pending {
|
||||
delete(f.pendingSpot, seq)
|
||||
delete(f.pendingSpotMode, seq)
|
||||
}
|
||||
if pending && ok && len(parts) >= 3 {
|
||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||
f.spotCall[idx] = call
|
||||
f.spotMode[idx] = spotMode
|
||||
f.spotIdx[idx] = true
|
||||
f.spotByCall[strings.ToUpper(call)] = idx
|
||||
}
|
||||
@@ -767,17 +789,11 @@ func (f *Flex) handleStatus(payload string) {
|
||||
f.meterMeta[num] = old
|
||||
}
|
||||
f.mu.Unlock()
|
||||
// One line for the whole batch, not one per meter: a Flex announces
|
||||
// dozens at connect and that was a wall of text every time.
|
||||
var names []string
|
||||
// Not logged: the radio announces meters ONE per status line, so even
|
||||
// a batched line meant dozens of them at every connect.
|
||||
for _, id := range newIDs {
|
||||
mi := f.meterMeta[id]
|
||||
names = append(names, nonEmpty(mi.name, strconv.Itoa(id)))
|
||||
f.subscribeMeter(id)
|
||||
}
|
||||
if len(names) > 0 {
|
||||
debugLog.Printf("Flex: subscribed to %d meter(s): %s", len(names), strings.Join(names, " "))
|
||||
}
|
||||
}
|
||||
// Spot status: "spot <index> …". Track the index so we can clear the
|
||||
// panadapter, and log it verbatim — a click on a panadapter spot pushes a
|
||||
@@ -796,6 +812,7 @@ func (f *Flex) handleStatus(payload string) {
|
||||
if removed {
|
||||
delete(f.spotIdx, idx)
|
||||
delete(f.spotCall, idx)
|
||||
delete(f.spotMode, idx)
|
||||
} else {
|
||||
f.spotIdx[idx] = true
|
||||
}
|
||||
@@ -968,7 +985,17 @@ func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
||||
// map order returned a RANDOM active slice each call → the operating frequency
|
||||
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
|
||||
// Deterministic order = the lowest-indexed active slice wins, stably.
|
||||
// An explicit choice made in OpsLog wins over everything, including the
|
||||
// radio's active flag. It is dropped only when that slice stops being in
|
||||
// use — a slice the operator closed is not a choice any more.
|
||||
if f.pinnedSlice >= 0 {
|
||||
if s := f.slices[f.pinnedSlice]; s != nil && s.inUse {
|
||||
return f.pinnedSlice, s
|
||||
}
|
||||
}
|
||||
|
||||
firstInUse := -1
|
||||
chosen := -1
|
||||
for _, idx := range f.sortedSliceIdxLocked() {
|
||||
s := f.slices[idx]
|
||||
if !s.inUse {
|
||||
@@ -978,11 +1005,56 @@ func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
||||
firstInUse = idx
|
||||
}
|
||||
if s.active {
|
||||
return idx, s
|
||||
chosen = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstInUse >= 0 {
|
||||
return firstInUse, f.slices[firstInUse]
|
||||
if chosen < 0 {
|
||||
chosen = firstInUse
|
||||
}
|
||||
if chosen < 0 {
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
// In SPLIT, stay on the RECEIVE slice.
|
||||
//
|
||||
// SmartSDR moves its "active" flag to the TX slice as soon as the transmit
|
||||
// frequency is touched, so OpsLog followed the transmitter. But the operator
|
||||
// is LISTENING to the DX on the other slice: the S-meter, the audio level,
|
||||
// the filter and the DSP they are adjusting all belong there, and following
|
||||
// the TX slice hands them the controls for a receiver they are not using.
|
||||
//
|
||||
// Only in split, and only when nothing was pinned above — clicking slice B
|
||||
// in OpsLog still selects it and keeps it.
|
||||
if txS := f.txSliceLocked(); txS != nil && f.slices[chosen] == txS {
|
||||
if rxIdx, rxS := f.splitPartnerLocked(txS); rxS != nil {
|
||||
return rxIdx, rxS
|
||||
}
|
||||
}
|
||||
return chosen, f.slices[chosen]
|
||||
}
|
||||
|
||||
// splitPartnerLocked returns the slice that FORMS A SPLIT with txS: in use, on
|
||||
// the same band, at a different frequency, in the same class (phone with phone,
|
||||
// CW with CW — so SSB alongside FT8 on one band is not a split).
|
||||
//
|
||||
// Lowest index first, so the answer is stable; map order is randomised in Go
|
||||
// and gave a different partner on each poll. Caller holds f.mu.
|
||||
func (f *Flex) splitPartnerLocked(txS *flexSlice) (int, *flexSlice) {
|
||||
if txS == nil {
|
||||
return -1, nil
|
||||
}
|
||||
bt := BandFromHz(txS.freqHz)
|
||||
ct := flexSplitClass(txS.mode)
|
||||
if bt == "" || ct == "" {
|
||||
return -1, nil
|
||||
}
|
||||
for _, idx := range f.sortedSliceIdxLocked() {
|
||||
s := f.slices[idx]
|
||||
if s != nil && s.inUse && s != txS && s.freqHz != txS.freqHz &&
|
||||
BandFromHz(s.freqHz) == bt && flexSplitClass(s.mode) == ct {
|
||||
return idx, s
|
||||
}
|
||||
}
|
||||
return -1, nil
|
||||
}
|
||||
@@ -1089,6 +1161,11 @@ func (f *Flex) SetActiveSlice(idx int) error {
|
||||
if !exists {
|
||||
return fmt.Errorf("flex: no slice %d", idx)
|
||||
}
|
||||
// Remember it: this is the operator speaking, and it must survive the radio
|
||||
// moving its own active flag to the transmitter during split.
|
||||
f.mu.Lock()
|
||||
f.pinnedSlice = idx
|
||||
f.mu.Unlock()
|
||||
f.send(fmt.Sprintf("slice s %d active=1", idx))
|
||||
return nil
|
||||
}
|
||||
@@ -1198,6 +1275,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
||||
if hadOld {
|
||||
delete(f.spotByCall, upperCall)
|
||||
delete(f.spotCall, old)
|
||||
delete(f.spotMode, old)
|
||||
delete(f.spotIdx, old)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
@@ -1223,6 +1301,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
||||
// (trigger) can be resolved back to the callsign.
|
||||
f.mu.Lock()
|
||||
f.pendingSpot[seq] = s.Callsign
|
||||
f.pendingSpotMode[seq] = s.Mode
|
||||
f.mu.Unlock()
|
||||
}
|
||||
return nil
|
||||
@@ -1255,6 +1334,7 @@ func (f *Flex) ClearSpots() error {
|
||||
f.mu.Lock()
|
||||
f.spotIdx = map[int]bool{}
|
||||
f.spotCall = map[int]string{}
|
||||
f.spotMode = map[int]string{}
|
||||
f.spotByCall = map[string]int{}
|
||||
connected := f.conn != nil
|
||||
f.mu.Unlock()
|
||||
@@ -1389,6 +1469,7 @@ func (f *Flex) FlexState() FlexTXState {
|
||||
}
|
||||
}
|
||||
sort.Ints(sidx)
|
||||
mainIdx, _ := f.mainSliceLocked()
|
||||
for _, i := range sidx {
|
||||
s := f.slices[i]
|
||||
st.Slices = append(st.Slices, FlexSliceInfo{
|
||||
@@ -1397,7 +1478,11 @@ func (f *Flex) FlexState() FlexTXState {
|
||||
FreqHz: s.freqHz,
|
||||
Mode: flexModeToADIF(s.mode),
|
||||
Band: BandFromHz(s.freqHz),
|
||||
Active: s.active,
|
||||
// The slice OpsLog is working with, which is not always the one the
|
||||
// radio has focused — in split OpsLog stays on RX. Reporting the
|
||||
// radio's flag here would highlight one slice while every control
|
||||
// acted on another.
|
||||
Active: i == mainIdx,
|
||||
TX: s.tx,
|
||||
})
|
||||
}
|
||||
@@ -2190,13 +2275,6 @@ func (f *Flex) subscribeMeter(id int) {
|
||||
f.send(fmt.Sprintf("sub meter %d", id))
|
||||
}
|
||||
|
||||
func nonEmpty(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseFloatDefault(s string, def float64) float64 {
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
|
||||
return v
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package cat
|
||||
|
||||
import "testing"
|
||||
|
||||
// In split, OpsLog stays on the RECEIVE slice.
|
||||
//
|
||||
// SmartSDR moves its "active" flag onto the TX slice as soon as the transmit
|
||||
// frequency is touched. Following it hands the operator the S-meter, audio
|
||||
// level, filter and DSP of a receiver they are not listening to — while the DX
|
||||
// they are working is on the other slice.
|
||||
func TestMainSliceStaysOnRXInSplit(t *testing.T) {
|
||||
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
|
||||
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
|
||||
}
|
||||
|
||||
// Working a DX on 14.100, transmitting up on 14.200. The radio has focused
|
||||
// the transmitter.
|
||||
f := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
|
||||
0: mk(14_100_000, "USB", false, false), // where the DX is heard
|
||||
1: mk(14_200_000, "USB", true, true), // where we transmit, radio-focused
|
||||
}}
|
||||
idx, s := f.mainSliceLocked()
|
||||
if idx != 0 || s == nil || s.freqHz != 14_100_000 {
|
||||
t.Errorf("main slice = %d (%v Hz) — want slice 0, the RX side on 14.100", idx, s.freqHz)
|
||||
}
|
||||
|
||||
// Simplex: the radio's focus is authoritative again, nothing to prefer.
|
||||
f2 := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
|
||||
0: mk(14_100_000, "USB", false, false),
|
||||
1: mk(14_200_000, "USB", true, true),
|
||||
}}
|
||||
f2.slices[0].inUse = false // only the TX slice is in use → simplex
|
||||
if idx, _ := f2.mainSliceLocked(); idx != 1 {
|
||||
t.Errorf("simplex main slice = %d, want 1 — the only slice in use", idx)
|
||||
}
|
||||
|
||||
// Two same-band slices in DIFFERENT classes are not a split (SSB + FT8), so
|
||||
// there is nothing to prefer and the radio's focus stands.
|
||||
f3 := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
|
||||
0: mk(14_074_000, "DIGU", false, false),
|
||||
1: mk(14_200_000, "USB", true, true),
|
||||
}}
|
||||
if idx, _ := f3.mainSliceLocked(); idx != 1 {
|
||||
t.Errorf("SSB+FT8 main slice = %d, want 1 — that is not a split", idx)
|
||||
}
|
||||
}
|
||||
|
||||
// A slice picked IN OPSLOG wins over everything, including the split rule and
|
||||
// the radio's own focus. Picking one on the radio does not move OpsLog.
|
||||
func TestPinnedSliceWins(t *testing.T) {
|
||||
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
|
||||
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
|
||||
}
|
||||
f := &Flex{pinnedSlice: 1, slices: map[int]*flexSlice{
|
||||
0: mk(14_100_000, "USB", true, false), // radio-focused
|
||||
1: mk(14_200_000, "USB", false, true), // chosen in OpsLog
|
||||
}}
|
||||
if idx, _ := f.mainSliceLocked(); idx != 1 {
|
||||
t.Errorf("main slice = %d — an explicit choice in OpsLog must win", idx)
|
||||
}
|
||||
|
||||
// A pin on a slice that is no longer in use is not a choice any more: it
|
||||
// must not strand OpsLog on a receiver that has been closed.
|
||||
f.slices[1].inUse = false
|
||||
if idx, _ := f.mainSliceLocked(); idx != 0 {
|
||||
t.Errorf("main slice = %d — a closed slice cannot stay pinned", idx)
|
||||
}
|
||||
}
|
||||
+99
-22
@@ -114,6 +114,15 @@ type IcomSerial struct {
|
||||
silentGrace time.Duration // current width of that tolerance (backs off, see ReadState)
|
||||
dspLoaded bool // readDSP has run since the rig became responsive (loads all
|
||||
// the panel's set-once controls once the rig actually answers)
|
||||
// When the console last asked for the DSP snapshot. The meters and the
|
||||
// front-panel rotation are polled ONLY while something is displaying them:
|
||||
// they are half of OpsLog's CI-V traffic, and on a shared bus — a microHAM
|
||||
// Router splitting one CI-V link into two virtual ports, OpsLog on one and
|
||||
// WSJT-X/MSHV on the other — every frame we don't need is a frame that can
|
||||
// collide with the other program's.
|
||||
dspWantMu sync.Mutex
|
||||
dspWantAt time.Time
|
||||
|
||||
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
|
||||
lastSetFreqAt time.Time
|
||||
|
||||
@@ -409,15 +418,25 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
}
|
||||
|
||||
// Live meters + TX state for the Icom panel (the rig doesn't push these).
|
||||
// The meters are polled only while the console is actually on screen: they
|
||||
// were three CI-V round trips per cycle, every cycle, whether or not anyone
|
||||
// could see them. The previous readings are kept so the panel opens on the
|
||||
// last known values rather than on zeros.
|
||||
tx := b.readTX()
|
||||
sm, _ := b.readMeter(civ.SubMeterS)
|
||||
po, swr := 0, 0
|
||||
if tx {
|
||||
if v, ok := b.readMeter(civ.SubMeterPo); ok {
|
||||
po = v
|
||||
}
|
||||
if v, ok := b.readMeter(civ.SubMeterSWR); ok {
|
||||
swr = v
|
||||
watched := b.panelWatched()
|
||||
b.dspMu.Lock()
|
||||
sm, po, swr := b.dsp.SMeter, b.dsp.PowerMeter, b.dsp.SWRMeter
|
||||
b.dspMu.Unlock()
|
||||
if watched {
|
||||
sm, _ = b.readMeter(civ.SubMeterS)
|
||||
po, swr = 0, 0
|
||||
if tx {
|
||||
if v, ok := b.readMeter(civ.SubMeterPo); ok {
|
||||
po = v
|
||||
}
|
||||
if v, ok := b.readMeter(civ.SubMeterSWR); ok {
|
||||
swr = v
|
||||
}
|
||||
}
|
||||
}
|
||||
b.dspMu.Lock()
|
||||
@@ -434,11 +453,15 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
// snapshot so the panel's antenna, sliders, RIT, notch, etc. reflect the rig
|
||||
// instead of sitting at their zero defaults. Runs once; ↻ Refresh re-reads on
|
||||
// demand, and a reconnect re-arms it (Connect clears dspLoaded).
|
||||
if !b.dspLoaded {
|
||||
b.readDSP()
|
||||
b.dspLoaded = true
|
||||
} else {
|
||||
b.refreshFrontPanel()
|
||||
// Both of these only make sense for the console — don't spend the bus on them
|
||||
// while it is closed. The snapshot then loads the first time it is opened.
|
||||
if watched {
|
||||
if !b.dspLoaded {
|
||||
b.readDSP()
|
||||
b.dspLoaded = true
|
||||
} else {
|
||||
b.refreshFrontPanel()
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -532,12 +555,25 @@ func (b *IcomSerial) SetPower(on bool) error {
|
||||
return fmt.Errorf("icom: not connected")
|
||||
}
|
||||
if on {
|
||||
buf := make([]byte, 0, 32)
|
||||
for i := 0; i < 25; i++ {
|
||||
// The preamble has to last long enough IN TIME for the sleeping rig to
|
||||
// notice it, so its length scales with the baud rate — Icom's own table
|
||||
// asks for 7 bytes at 4800 baud and 150 at 115200, which is a fixed
|
||||
// ~25 ms of carrier. A flat 25 bytes (what this sent before) is right at
|
||||
// 19200 and far too short above it, so a rig configured for 115200 simply
|
||||
// ignored the command.
|
||||
n := b.baud / 768
|
||||
if n < 7 {
|
||||
n = 7
|
||||
} else if n > 150 {
|
||||
n = 150
|
||||
}
|
||||
buf := make([]byte, 0, n+8)
|
||||
for i := 0; i < n; i++ {
|
||||
buf = append(buf, 0xFE)
|
||||
}
|
||||
buf = append(buf, 0xFE, 0xFE, b.rigAddr, civ.AddrController, civ.CmdPower, 0x01, 0xFD)
|
||||
_, err := b.port.Write(buf)
|
||||
applog.Printf("icom: power ON — %d-byte wake preamble at %d baud, addr 0x%02X (err=%v)", n, b.baud, b.rigAddr, err)
|
||||
return err
|
||||
}
|
||||
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, civ.CmdPower, 0x00))
|
||||
@@ -557,7 +593,9 @@ func (b *IcomSerial) write(payload ...byte) error {
|
||||
// recv() only sees the reply to THIS request (avoids a previous command's ack
|
||||
// or an unsolicited dial-turn update being mistaken for our response).
|
||||
b.drainResp()
|
||||
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, payload...))
|
||||
frame := civ.Frame(b.rigAddr, civ.AddrController, payload...)
|
||||
traceCIV("TX", frame)
|
||||
_, err := b.port.Write(frame)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -603,6 +641,9 @@ func (b *IcomSerial) reader(port civTransport, done chan struct{}) {
|
||||
continue // read timeout with no data
|
||||
}
|
||||
rx = append(rx, tmp[:n]...)
|
||||
// Traced BEFORE framing: the bytes as they arrived are what shows a lost
|
||||
// frame boundary, which a decoded view would hide.
|
||||
traceCIV("RX", tmp[:n])
|
||||
frames, consumed := civ.Scan(rx)
|
||||
if consumed > 0 {
|
||||
rx = append(rx[:0], rx[consumed:]...)
|
||||
@@ -750,8 +791,13 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
||||
// (e.g. 14.200 MHz + 100 kHz → centred 14.150-14.250; 21.000 +
|
||||
// 21.070 → fixed 21.000-21.070). Guessing wrong here gave the absurd
|
||||
// 21.000-42.070 span (low + a 21 MHz "span").
|
||||
v1 := civ.BCDToFreq(region[1:6])
|
||||
v2 := civ.BCDToFreq(region[6:11])
|
||||
v1, ok1 := civ.BCDToFreq(region[1:6])
|
||||
v2, ok2 := civ.BCDToFreq(region[6:11])
|
||||
if !ok1 || !ok2 {
|
||||
// Not decimal BCD — the frame was read at the wrong offset. Keep the
|
||||
// edges we had; a wrong span is drawn, noticed, and mistrusted.
|
||||
break
|
||||
}
|
||||
var low, high int64
|
||||
if v2 > v1 {
|
||||
low, high = v1, v2 // absolute low/high edges (fixed edge set)
|
||||
@@ -788,8 +834,13 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
||||
// high = span (e.g. 100 kHz), so high < low and the panadapter had
|
||||
// no valid range to map the trace onto → blank scope. FIXED mode
|
||||
// parsed fine, which is why only center mode was broken.
|
||||
v1 := civ.BCDToFreq(region[1:6])
|
||||
v2 := civ.BCDToFreq(region[6:11])
|
||||
v1, ok1 := civ.BCDToFreq(region[1:6])
|
||||
v2, ok2 := civ.BCDToFreq(region[6:11])
|
||||
if !ok1 || !ok2 {
|
||||
// Not decimal BCD — the frame was read at the wrong offset. Keep the
|
||||
// edges we had; a wrong span is drawn, noticed, and mistrusted.
|
||||
break
|
||||
}
|
||||
var low, high int64
|
||||
if v2 > v1 {
|
||||
low, high = v1, v2 // absolute low/high edges (fixed)
|
||||
@@ -1142,7 +1193,14 @@ func (b *IcomSerial) readFreq() (int64, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return civ.BCDToFreq(f.Data), nil
|
||||
hz, ok := civ.BCDToFreq(f.Data)
|
||||
if !ok {
|
||||
// A frame that is not decimal BCD is a desynchronised read, not a
|
||||
// frequency. Reporting the error keeps the caller on its last good value
|
||||
// instead of publishing a number like 16445550000 Hz to the display.
|
||||
return 0, fmt.Errorf("icom: frequency frame is not BCD: % X", f.Data)
|
||||
}
|
||||
return hz, nil
|
||||
}
|
||||
|
||||
// readSplit reads the rig's split state (CI-V 0x0F). 0x01 = split on; 0x10/0x11
|
||||
@@ -1172,7 +1230,11 @@ func (b *IcomSerial) readTXFreq() (int64, bool) {
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return civ.BCDToFreq(f.Data[1:]), true
|
||||
hz, ok := civ.BCDToFreq(f.Data[1:])
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return hz, true
|
||||
}
|
||||
|
||||
// readTX reads the transmit state (CI-V 0x1C 0x00): non-zero data = keyed.
|
||||
@@ -1263,11 +1325,26 @@ func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
|
||||
// ── IcomController: receive-DSP controls for the Icom tab ───────────────────
|
||||
|
||||
func (b *IcomSerial) IcomState() IcomTXState {
|
||||
// Asking for the snapshot is what marks the console as being watched, which
|
||||
// is what re-enables the meter polling in ReadState.
|
||||
b.dspWantMu.Lock()
|
||||
b.dspWantAt = time.Now()
|
||||
b.dspWantMu.Unlock()
|
||||
|
||||
b.dspMu.Lock()
|
||||
defer b.dspMu.Unlock()
|
||||
return b.dsp
|
||||
}
|
||||
|
||||
// panelWatched reports whether the Icom console asked for the DSP snapshot
|
||||
// recently — i.e. whether anything is on screen to read the meters.
|
||||
func (b *IcomSerial) panelWatched() bool {
|
||||
b.dspWantMu.Lock()
|
||||
at := b.dspWantAt
|
||||
b.dspWantMu.Unlock()
|
||||
return !at.IsZero() && time.Since(at) < 3*time.Second
|
||||
}
|
||||
|
||||
// RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT
|
||||
// goroutine (dispatched via IcomDo).
|
||||
func (b *IcomSerial) RefreshIcom() error {
|
||||
|
||||
@@ -0,0 +1,755 @@
|
||||
package cat
|
||||
|
||||
// Native Kenwood CAT — TS-590, TS-890, TS-2000 and the many rigs that speak the
|
||||
// same dialect (Elecraft K3/K4, and the "Kenwood/Elecraft" setting on Flex and
|
||||
// SunSDR). Plain ASCII, every command terminated by ';', same shape as Yaesu but
|
||||
// a different vocabulary.
|
||||
//
|
||||
// The dialect is already proven in this repository from the other side:
|
||||
// internal/catemu ANSWERS these commands, pretending to be a TS-2000 so an ACOM
|
||||
// amplifier follows OpsLog. The frame layouts here and there are the same ones.
|
||||
//
|
||||
// Commands used:
|
||||
//
|
||||
// FA; → FA00014025000; VFO A frequency, ELEVEN digits, Hz
|
||||
// FB; → FB00014030000; VFO B frequency
|
||||
// IF; → 38-char status frame: frequency, RX/TX, mode, VFO, split —
|
||||
// the whole operating state in ONE round trip, which is why it
|
||||
// is the poll rather than asking four separate questions.
|
||||
// MD; → MD3; mode (1=LSB 2=USB 3=CW 4=FM 5=AM 6=FSK
|
||||
// 7=CW-R 9=FSK-R)
|
||||
// FR0;/FR1; receive VFO — 0 = A, 1 = B
|
||||
// FT0;/FT1; transmit VFO (split = the two differ)
|
||||
// TX;/RX; key / unkey
|
||||
// ID; → ID020; model number
|
||||
// AI0; silence unsolicited status reports
|
||||
//
|
||||
// Why not OmniRig: the same reason the Yaesu backend exists. Every Kenwood
|
||||
// fault reported through OmniRig came from its interpretation layer rather than
|
||||
// from the radio, and the rig file decides what a "Freq" property means.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// Kenwood is the native backend. One serial port, one mutex: a command and its
|
||||
// reply are never interleaved with another exchange.
|
||||
type Kenwood struct {
|
||||
portName string
|
||||
baud int
|
||||
// host is "address:port" for a serial link reached over the network — a
|
||||
// ser2net daemon, an Ethernet-serial adapter, a Raspberry Pi in the shack.
|
||||
//
|
||||
// This is NOT Kenwood's own network protocol. A TS-890 or TS-990 speaks
|
||||
// KNS/ARCP over its Ethernet socket, with a session and authentication, and
|
||||
// that is a different piece of work needing one of those radios to confirm
|
||||
// it. What this covers is the same CAT byte stream over a socket instead of
|
||||
// a wire, which is how most operators actually put a rig on the network.
|
||||
host string
|
||||
digital string // mode name logged for data (FT8 by default)
|
||||
// dataMode chooses what CAT mode a DATA/digital mode (FT8, PSK…) sets, because
|
||||
// no single Kenwood mode digit is right for every rig: "usb" → USB (the SSB-data
|
||||
// base, default), "data" → the DATA mode digit MD6 (Elecraft K3/K4), "keep" →
|
||||
// leave the rig's current mode untouched (safest for a TS-590SG/TS-990S whose
|
||||
// data mode is a USB modifier the operator sets on the rig). See SetMode.
|
||||
dataMode string
|
||||
|
||||
mu sync.Mutex
|
||||
port serial.Port
|
||||
|
||||
// dialPort, when set, replaces serial.Open. It exists so the backend can be
|
||||
// driven against internal/catemu — which already SPEAKS this dialect to
|
||||
// satisfy an ACOM amplifier — without a radio, a COM port or a null-modem
|
||||
// pair. Written for a Kenwood backend nobody here owns a rig to test.
|
||||
dialPort func() (serial.Port, error)
|
||||
|
||||
model string
|
||||
curFreq int64
|
||||
curRXFreq int64
|
||||
curVFO string // "A" or "B"
|
||||
// Split is confirmed with FR;/FT; because IF's split bit is empty on some
|
||||
// Kenwood-dialect rigs — but that check is THROTTLED so it doesn't run every
|
||||
// poll: the two extra commands tripled the poll time on a slow K3 and made the
|
||||
// frequency (read from IF at the top of the poll) lag by seconds. Between
|
||||
// checks the last result stands.
|
||||
splitCheckAt time.Time
|
||||
splitCached bool
|
||||
splitVFOCached string
|
||||
keyWPM int // CW keyer speed, for pacing the KY buffer (see kenwood_cw.go)
|
||||
// Commands this rig answered "?;" to — asked once, then never again.
|
||||
unsupported map[string]bool
|
||||
|
||||
// rx holds bytes read but not yet consumed, ACROSS calls to ask.
|
||||
//
|
||||
// It has to survive: a rig answers faster than we ask, so one Read often
|
||||
// returns a whole reply plus the start of the next frame. When this buffer
|
||||
// was local to ask, everything after the matched frame was dropped — half a
|
||||
// frame included — and the link desynchronised permanently: every ask then
|
||||
// found the PREVIOUS command's answer and timed out waiting for its own.
|
||||
// That is the "discarding \" 000000000010000000;\" while waiting for IF"
|
||||
// a TS-480 reported, followed by connected=false for ever.
|
||||
rx []byte
|
||||
|
||||
// heard is whatever arrived during Connect that was not a reply we wanted.
|
||||
// Kept only to put it in the error message: "not answering" and "answering
|
||||
// something unreadable" are different faults with different fixes.
|
||||
heard string
|
||||
|
||||
// lowerLines deasserts DTR and RTS after opening the port.
|
||||
//
|
||||
// Neither default is safe for everyone, which is why this is a setting and
|
||||
// not a decision. Windows raises both lines on open: an interface that reads
|
||||
// them as PTT then keys the rig for as long as OpsLog is running — reported
|
||||
// as FT8 output power wandering, and gone the moment OpsLog was closed.
|
||||
// Lowering them instead silences the radios whose USB-serial interface needs
|
||||
// RTS asserted to transmit at all — a TS-990 that opened cleanly and answered
|
||||
// nothing. Off by default: that is how this backend behaved for its whole
|
||||
// life before the question came up.
|
||||
lowerLines bool
|
||||
|
||||
// tx tracks whether we currently hold PTT (SetPTT true). A Kenwood/Elecraft rig
|
||||
// answers "?;" to a status poll (IF;) WHILE TRANSMITTING; OpsLog used to read
|
||||
// that as "the rig doesn't support IF;", latch it off and drop the whole CAT
|
||||
// link — which tore down the shared-CAT PTT that WSJT-X / JTDX key through, so a
|
||||
// K3 keyed but the logger's transmission fell apart. While PTT is held we skip
|
||||
// the wire poll and hand back the last good state (lastState) instead. txAt caps
|
||||
// the skip so a missed SetPTT(false) can't freeze the state for ever.
|
||||
tx bool
|
||||
txAt time.Time
|
||||
lastState RigState
|
||||
}
|
||||
|
||||
// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
|
||||
// before Connect.
|
||||
func (k *Kenwood) SetLowerLines(v bool) {
|
||||
k.mu.Lock()
|
||||
k.lowerLines = v
|
||||
k.mu.Unlock()
|
||||
}
|
||||
|
||||
// where names the link for a message, so an error does not read "COM @ 0 baud"
|
||||
// after a network connect.
|
||||
func (k *Kenwood) where() string {
|
||||
if k.host != "" {
|
||||
return k.host
|
||||
}
|
||||
return k.portName
|
||||
}
|
||||
|
||||
// NewKenwoodTCP builds a backend that reaches the rig over a socket instead of
|
||||
// a COM port (ser2net and friends).
|
||||
func NewKenwoodTCP(hostPort, digital string) *Kenwood {
|
||||
k := NewKenwood("", 0, digital)
|
||||
k.host = strings.TrimSpace(hostPort)
|
||||
return k
|
||||
}
|
||||
|
||||
func NewKenwood(portName string, baud int, digital string) *Kenwood {
|
||||
if baud <= 0 {
|
||||
baud = 9600 // TS-590 factory default; TS-890 ships at 115200
|
||||
}
|
||||
if strings.TrimSpace(digital) == "" {
|
||||
digital = "FT8"
|
||||
}
|
||||
return &Kenwood{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
|
||||
}
|
||||
|
||||
func (k *Kenwood) Name() string { return "kenwood" }
|
||||
|
||||
func (k *Kenwood) Connect() error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.portName == "" && k.host == "" {
|
||||
return fmt.Errorf("kenwood: no serial port or network address configured")
|
||||
}
|
||||
// Close any handle still held before opening another: Connect runs again on
|
||||
// every reconnect, and Windows opens a serial port exclusively, so a leaked
|
||||
// handle makes the next open fail with "port busy" (the fault found in the
|
||||
// Yaesu backend — same shape here).
|
||||
if k.port != nil {
|
||||
_ = k.port.Close()
|
||||
k.port = nil
|
||||
}
|
||||
p, err := k.openPort()
|
||||
if err != nil {
|
||||
if k.host != "" {
|
||||
return fmt.Errorf("kenwood: connect %s: %w", k.host, err)
|
||||
}
|
||||
return fmt.Errorf("kenwood: open %s @ %d baud: %w", k.portName, k.baud, err)
|
||||
}
|
||||
p.SetReadTimeout(300 * time.Millisecond)
|
||||
k.port = p
|
||||
k.unsupported = map[string]bool{}
|
||||
|
||||
// Silence unsolicited status reports: they interleave with our
|
||||
// request/response pairs and make a reply impossible to attribute. We poll.
|
||||
_ = k.write("AI0;")
|
||||
|
||||
// Start from silence. A reconnect inherits whatever the rig said last —
|
||||
// unsolicited frames sent before AI0 landed, the tail of an answer nobody
|
||||
// read — and one stale frame is enough to leave every ask one reply behind
|
||||
// its question for the rest of the session.
|
||||
k.rx = nil
|
||||
k.drain(250 * time.Millisecond)
|
||||
k.heard = ""
|
||||
|
||||
answered := false
|
||||
if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") {
|
||||
answered = true
|
||||
code := strings.TrimSuffix(strings.TrimPrefix(id, "ID"), ";")
|
||||
if name, ok := kenwoodModels[code]; ok {
|
||||
k.model = name
|
||||
} else {
|
||||
k.model = "Kenwood (" + code + ")"
|
||||
debugLog.Printf("kenwood: unknown model id %q — add it to kenwoodModels", code)
|
||||
}
|
||||
}
|
||||
// IF is the command everything else depends on, so it is also the honest
|
||||
// test of whether a radio is really there.
|
||||
if r, err := k.ask("IF;"); err == nil && strings.HasPrefix(r, "IF") {
|
||||
answered = true
|
||||
}
|
||||
if !answered {
|
||||
k.model = ""
|
||||
if k.heard == "" && len(k.rx) > 0 {
|
||||
k.heard = string(k.rx) // an unterminated fragment is evidence too
|
||||
}
|
||||
// Distinguish silence from noise. "The rig is not answering" sent an
|
||||
// operator checking the power switch and the baud rate on a radio that was
|
||||
// visibly talking — its frames were arriving, they just did not match what
|
||||
// was asked (wrong baud garbles them; an interface echoing our own
|
||||
// commands back produces the same). Say which of the two it is, and quote
|
||||
// what came back, because that is the fact that decides where to look.
|
||||
if seen := k.heard; seen != "" {
|
||||
return fmt.Errorf("kenwood: %s is sending data but no reply to ID; or IF; — got %q. Check the baud rate (set to %d here) and that nothing else is echoing the port", k.where(), seen, k.baud)
|
||||
}
|
||||
if k.host != "" {
|
||||
return fmt.Errorf("kenwood: %s accepted the connection but the rig sent nothing — check that the serial bridge points at the radio and that the radio is switched on", k.host)
|
||||
}
|
||||
return fmt.Errorf("kenwood: %s opened but the rig sent nothing — check that it is switched on and set to %d baud", k.portName, k.baud)
|
||||
}
|
||||
// Name what was actually connected to. A log reading "connected on @ 0 baud"
|
||||
// after a network connect is the kind of line that sends someone hunting a
|
||||
// serial fault that does not exist.
|
||||
if k.host != "" {
|
||||
debugLog.Printf("kenwood: connected to %s (network serial bridge), model=%q", k.host, k.model)
|
||||
} else {
|
||||
debugLog.Printf("kenwood: connected on %s @ %d baud, model=%q", k.portName, k.baud, k.model)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *Kenwood) Disconnect() {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.port != nil {
|
||||
_ = k.port.Close()
|
||||
k.port = nil
|
||||
}
|
||||
}
|
||||
|
||||
// ReadState polls the rig. IF carries frequency, mode, VFO, split and TX state
|
||||
// in one frame; the other VFO is only asked for when split is actually on, so
|
||||
// the common simplex case costs a single round trip.
|
||||
func (k *Kenwood) ReadState() (RigState, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.port == nil {
|
||||
return RigState{}, fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
// While transmitting, don't poll: the rig returns "?;" to IF; during TX, and
|
||||
// treating that as a fault dropped the shared CAT link the digital-mode
|
||||
// software keys through. Hand back the last known state. The 30 s cap resyncs
|
||||
// if a SetPTT(false) was somehow missed, so a stuck TX can't freeze state.
|
||||
if k.tx && !k.txAt.IsZero() && time.Since(k.txAt) < 30*time.Second {
|
||||
s := k.lastState
|
||||
s.Connected = true
|
||||
return s, nil
|
||||
}
|
||||
raw, err := k.ask("IF;")
|
||||
if err != nil {
|
||||
return RigState{}, err
|
||||
}
|
||||
f, ok := parseKenwoodIF(raw)
|
||||
if !ok {
|
||||
return RigState{}, fmt.Errorf("kenwood: unparsable IF frame %q", raw)
|
||||
}
|
||||
|
||||
s := RigState{Connected: true, Backend: "kenwood"}
|
||||
k.curVFO = f.VFO
|
||||
s.Vfo = f.VFO
|
||||
s.Mode = kenwoodModeToADIF(f.Mode, k.digital)
|
||||
s.Rig = k.model
|
||||
|
||||
// IF reports the frequency of the VFO in USE (what the operator hears).
|
||||
rx := f.FreqHz
|
||||
tx := rx
|
||||
|
||||
// IF's split bit is not filled in by every rig that speaks this dialect —
|
||||
// reported on a Flex through its Kenwood CAT emulation, where the frequency
|
||||
// reads perfectly and split never appears. So ask the question directly as
|
||||
// well: split IS "the transmit VFO differs from the receive VFO", which is
|
||||
// what FR/FT answer, and it is the same rule the Yaesu backend settled on.
|
||||
//
|
||||
// A rig that rejects FR/FT answers "?;" once and is never asked again, so
|
||||
// this costs two short commands per poll only where it actually works.
|
||||
split := f.Split
|
||||
if !split {
|
||||
// Confirm with FR;/FT; only once a second, not every poll. Running the two
|
||||
// extra commands each cycle tripled the poll time on a slow K3, so the
|
||||
// frequency — already read from IF above — only surfaced every couple of
|
||||
// seconds. Between checks the last FR/FT result stands.
|
||||
if time.Since(k.splitCheckAt) >= time.Second {
|
||||
k.splitCheckAt = time.Now()
|
||||
k.splitCached, k.splitVFOCached = false, ""
|
||||
rxv, rxOK := k.askVFO("FR;")
|
||||
txv, txOK := k.askVFO("FT;")
|
||||
if rxOK && txOK && rxv != txv {
|
||||
k.splitCached = true
|
||||
// Trust FR over IF for which VFO is in use: they were asked in the
|
||||
// same breath, and a rig that leaves the split bit empty may be just
|
||||
// as vague about the VFO field. The block below picks the TRANSMIT
|
||||
// VFO as "the other one" from f.VFO, so leaving them disagreeing
|
||||
// would read the transmit frequency off the wrong dial.
|
||||
if rxv == "A" || rxv == "B" {
|
||||
k.splitVFOCached = rxv
|
||||
}
|
||||
}
|
||||
}
|
||||
if k.splitCached {
|
||||
split = true
|
||||
if k.splitVFOCached != "" {
|
||||
k.curVFO, s.Vfo, f.VFO = k.splitVFOCached, k.splitVFOCached, k.splitVFOCached
|
||||
}
|
||||
}
|
||||
}
|
||||
f.Split = split
|
||||
|
||||
if f.Split {
|
||||
// The transmit VFO is the other one. Read it rather than assume, and fall
|
||||
// back to simplex if it cannot be read: a wrong TX frequency is written
|
||||
// into the log, which is worse than showing no split at all.
|
||||
other := "FB;"
|
||||
if f.VFO == "B" {
|
||||
other = "FA;"
|
||||
}
|
||||
if r, err := k.ask(other); err == nil {
|
||||
if hz, ok := parseKenwoodFreq(r, strings.TrimSuffix(other, ";")); ok && hz > 0 && hz != rx {
|
||||
tx = hz
|
||||
}
|
||||
}
|
||||
}
|
||||
if tx != rx {
|
||||
s.FreqHz = tx // ADIF: FREQ is the TRANSMIT frequency
|
||||
s.RxFreqHz = rx
|
||||
s.Split = true
|
||||
} else {
|
||||
s.FreqHz = rx
|
||||
}
|
||||
k.curFreq = s.FreqHz
|
||||
if s.Split {
|
||||
k.curRXFreq = s.RxFreqHz
|
||||
}
|
||||
k.lastState = s // cache for the transmit window, where we can't poll
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// SetFrequency tunes the VFO the operator is actually on — writing FA blind is
|
||||
// what makes a display disagree with the radio when they are on B.
|
||||
func (k *Kenwood) SetFrequency(hz int64) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
if hz <= 0 || hz > 99_999_999_999 {
|
||||
return fmt.Errorf("kenwood: frequency %d out of the 11-digit CAT range", hz)
|
||||
}
|
||||
cmd := "FA"
|
||||
if k.curVFO == "B" {
|
||||
cmd = "FB"
|
||||
}
|
||||
return k.write(fmt.Sprintf("%s%011d;", cmd, hz))
|
||||
}
|
||||
|
||||
func (k *Kenwood) SetMode(mode string) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
// Honour the operator's DATA-mode preference for a digital mode. No single
|
||||
// mode digit fits every rig, so the operator picks: keep the rig's mode, use
|
||||
// MD6 (K3/K4 DATA), or fall through to the default USB from kenwoodModeDigit.
|
||||
if isKenwoodDataMode(mode) {
|
||||
switch k.dataMode {
|
||||
case "keep":
|
||||
return nil // leave whatever data mode the operator set on the rig
|
||||
case "data":
|
||||
return k.write("MD6;") // Elecraft K3/K4 DATA mode
|
||||
}
|
||||
}
|
||||
d := kenwoodModeDigit(mode, k.curFreq)
|
||||
if d == 0 {
|
||||
return fmt.Errorf("kenwood: no CAT mode for %q", mode)
|
||||
}
|
||||
return k.write(fmt.Sprintf("MD%c;", d))
|
||||
}
|
||||
|
||||
// SetDataMode configures how a DATA/digital mode is set: "usb" (default), "data"
|
||||
// (MD6, Elecraft K3/K4) or "keep" (don't change the rig's mode).
|
||||
func (k *Kenwood) SetDataMode(m string) {
|
||||
k.mu.Lock()
|
||||
k.dataMode = strings.ToLower(strings.TrimSpace(m))
|
||||
k.mu.Unlock()
|
||||
}
|
||||
|
||||
// isKenwoodDataMode reports whether a mode name is a soundcard/data mode (FT8,
|
||||
// PSK, JT…) rather than a voice/CW/RTTY mode the rig sets natively.
|
||||
func isKenwoodDataMode(mode string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "", "LSB", "USB", "SSB", "CW", "CW-R", "CWR", "FM", "NFM", "AM",
|
||||
"RTTY", "FSK", "RTTY-R", "FSK-R":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (k *Kenwood) SetPTT(on bool) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
k.tx = on
|
||||
if on {
|
||||
k.txAt = time.Now()
|
||||
return k.write("TX;")
|
||||
}
|
||||
return k.write("RX;")
|
||||
}
|
||||
|
||||
func (k *Kenwood) write(cmd string) error {
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
traceText("kenwood", "TX", cmd)
|
||||
_, err := k.port.Write([]byte(cmd))
|
||||
return err
|
||||
}
|
||||
|
||||
// drain reads and throws away whatever the rig has already sent, until it stays
|
||||
// quiet for one read timeout or the budget runs out.
|
||||
func (k *Kenwood) drain(budget time.Duration) {
|
||||
if k.port == nil {
|
||||
return
|
||||
}
|
||||
tmp := make([]byte, 256)
|
||||
deadline := time.Now().Add(budget)
|
||||
for time.Now().Before(deadline) {
|
||||
n, err := k.port.Read(tmp)
|
||||
if err != nil || n == 0 {
|
||||
return // an error here is not interesting: we are throwing this away
|
||||
}
|
||||
traceText("kenwood", "RX-drop", string(tmp[:n]))
|
||||
}
|
||||
}
|
||||
|
||||
// ask sends a query and returns the reply belonging to THAT command. Anything
|
||||
// else on the wire is discarded: a stray frame parsed as a frequency reads as
|
||||
// "lost the rig" to the Manager, which then reconnects — the CAT link dropping
|
||||
// for no reason (found the hard way on the Yaesu backend).
|
||||
func (k *Kenwood) ask(cmd string) (string, error) {
|
||||
want := cmdPrefix(cmd)
|
||||
if k.unsupported[want] {
|
||||
return "", fmt.Errorf("kenwood: %s is not supported by this rig", want)
|
||||
}
|
||||
if err := k.write(cmd); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmp := make([]byte, 64)
|
||||
deadline := time.Now().Add(600 * time.Millisecond)
|
||||
for {
|
||||
// Consume whatever is already buffered BEFORE reading more: the answer
|
||||
// may have arrived attached to the previous one.
|
||||
for {
|
||||
i := bytes.IndexByte(k.rx, ';')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
frame := string(k.rx[:i+1])
|
||||
k.rx = k.rx[i+1:]
|
||||
traceText("kenwood", "RX", frame)
|
||||
if frame == "?;" {
|
||||
// IF; and ID; are universal on Kenwood/Elecraft — a "?;" to them is a
|
||||
// transient "busy" (typically mid-transmit, or a menu open on the rig),
|
||||
// NOT "unsupported". Latching them off would blind the poll loop for
|
||||
// good and read as "lost the rig". Only remember the OPTIONAL commands
|
||||
// (FR/FT/…) so the poll loop stops paying a 600 ms timeout for those.
|
||||
if want != "IF" && want != "ID" {
|
||||
k.unsupported[want] = true
|
||||
debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd)
|
||||
}
|
||||
return "", fmt.Errorf("kenwood: %s rejected", want)
|
||||
}
|
||||
if strings.HasPrefix(frame, want) {
|
||||
return frame, nil
|
||||
}
|
||||
debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want)
|
||||
// Remember the first unexpected frame: if the whole handshake fails, this
|
||||
// is what tells the operator the radio was talking after all.
|
||||
if k.heard == "" {
|
||||
k.heard = frame
|
||||
}
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
|
||||
}
|
||||
n, err := k.port.Read(tmp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n > 0 {
|
||||
k.rx = append(k.rx, tmp[:n]...)
|
||||
}
|
||||
// n == 0 is a read timeout, not silence for good: the rig may still be
|
||||
// composing its answer.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Frame parsing ──────────────────────────────────────────────────────────
|
||||
|
||||
// kenwoodIF is what the 38-character IF status frame carries.
|
||||
type kenwoodIF struct {
|
||||
FreqHz int64
|
||||
Mode byte
|
||||
VFO string // "A" or "B"
|
||||
Split bool
|
||||
// TX is parsed even though RigState has no PTT field: it is one character of
|
||||
// the same frame, and having it here means a future "transmitting" indicator
|
||||
// costs no extra round trip.
|
||||
TX bool
|
||||
}
|
||||
|
||||
// parseKenwoodIF reads the TS-2000/TS-590 status frame. Its layout — the same
|
||||
// one internal/catemu emits — is:
|
||||
//
|
||||
// IF | freq(11) | step(4) | RIT(±5) | RIT/XIT/bank(3) | mem(2) | rx-tx(1) |
|
||||
// mode(1) | VFO(1) | scan(1) | split(1) | tone(1) | tone#(2) | shift(1) | ;
|
||||
//
|
||||
// Fields are read by POSITION, so the length is checked first: a short frame
|
||||
// means a truncated read, and indexing into it would panic or, worse, silently
|
||||
// yield a wrong frequency.
|
||||
func parseKenwoodIF(reply string) (kenwoodIF, bool) {
|
||||
r := strings.TrimSpace(reply)
|
||||
if !strings.HasPrefix(r, "IF") || len(r) < 38 {
|
||||
return kenwoodIF{}, false
|
||||
}
|
||||
hz, err := strconv.ParseInt(strings.TrimSpace(r[2:13]), 10, 64)
|
||||
if err != nil || hz <= 0 {
|
||||
return kenwoodIF{}, false
|
||||
}
|
||||
out := kenwoodIF{FreqHz: hz, Mode: r[29], VFO: "A", TX: r[28] == '1', Split: r[32] == '1'}
|
||||
if r[30] == '1' {
|
||||
out.VFO = "B"
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// parseKenwoodFreq reads an FA/FB reply — ELEVEN digits on Kenwood, where Yaesu
|
||||
// uses nine. The prefix is checked so an FB reply is never accepted as FA.
|
||||
func parseKenwoodFreq(reply, prefix string) (int64, bool) {
|
||||
r := strings.TrimSpace(reply)
|
||||
if !strings.HasPrefix(r, prefix) {
|
||||
return 0, false
|
||||
}
|
||||
digits := strings.TrimSuffix(strings.TrimPrefix(r, prefix), ";")
|
||||
if digits == "" {
|
||||
return 0, false
|
||||
}
|
||||
hz, err := strconv.ParseInt(digits, 10, 64)
|
||||
if err != nil || hz <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return hz, true
|
||||
}
|
||||
|
||||
// ── Modes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// kenwoodModeDigit maps an ADIF mode to the Kenwood digit. The sideband follows
|
||||
// the frequency by worldwide convention — a backend that puts USB on 40 m makes
|
||||
// every SSB QSO in the log wrong.
|
||||
func kenwoodModeDigit(mode string, hz int64) byte {
|
||||
m := strings.ToUpper(strings.TrimSpace(mode))
|
||||
switch m {
|
||||
case "":
|
||||
return 0
|
||||
case "LSB":
|
||||
return '1'
|
||||
case "USB":
|
||||
return '2'
|
||||
case "CW":
|
||||
return '3'
|
||||
case "CW-R", "CWR":
|
||||
return '7'
|
||||
case "FM":
|
||||
return '4'
|
||||
case "AM":
|
||||
return '5'
|
||||
case "RTTY", "FSK":
|
||||
return '6'
|
||||
case "RTTY-R", "FSK-R":
|
||||
return '9'
|
||||
case "SSB":
|
||||
if hz > 0 && hz < 10_000_000 {
|
||||
return '1'
|
||||
}
|
||||
return '2'
|
||||
}
|
||||
// Any other digital mode (FT8, PSK, JT…) → USB on every band. There is NO single
|
||||
// mode digit that is right for both families: a K3 wants DATA (MD6), a TS-590SG
|
||||
// wants USB-DATA (MD2 + its DATA modifier), and MD6 on the TS-590SG is FSK/RTTY
|
||||
// — wrong for FT8. USB is the safe common base (the reversed-sideband "DATA REV"
|
||||
// only came from the old LSB-below-10-MHz rule). A rig-specific DATA mode is a
|
||||
// follow-up that needs the model, not a blind default.
|
||||
return '2'
|
||||
}
|
||||
|
||||
// kenwoodModeToADIF turns the rig's mode digit into what the log records.
|
||||
// Digital modes are indistinguishable from SSB over CAT — the rig only knows
|
||||
// it is on USB — so the operator's configured digital mode is used, exactly as
|
||||
// the other backends do.
|
||||
func kenwoodModeToADIF(d byte, digital string) string {
|
||||
switch d {
|
||||
case '1':
|
||||
return "LSB"
|
||||
case '2':
|
||||
return "USB"
|
||||
case '3', '7':
|
||||
return "CW"
|
||||
case '4':
|
||||
return "FM"
|
||||
case '5':
|
||||
return "AM"
|
||||
case '6', '9':
|
||||
return "RTTY"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// kenwoodModels maps the ID reply to a name for the status bar. An unknown code
|
||||
// is shown as-is rather than refused: the model name is cosmetic, and a rig that
|
||||
// answers everything else must not be rejected over it.
|
||||
var kenwoodModels = map[string]string{
|
||||
"017": "TS-570",
|
||||
"019": "TS-2000",
|
||||
"020": "TS-480",
|
||||
"021": "TS-590S",
|
||||
// 022 reported by a real TS-990S in the field. Kenwood's documentation gives
|
||||
// 024 for that radio, so both are kept: the observed value wins where they
|
||||
// disagree, and neither maps to anything else.
|
||||
"022": "TS-990S",
|
||||
"023": "TS-590SG",
|
||||
"024": "TS-990S",
|
||||
"025": "TS-890S",
|
||||
}
|
||||
|
||||
// openPort opens the serial link, or whatever dialPort provides in a test.
|
||||
func (k *Kenwood) openPort() (serial.Port, error) {
|
||||
if k.dialPort != nil {
|
||||
return k.dialPort()
|
||||
}
|
||||
if k.host != "" {
|
||||
c, err := net.DialTimeout("tcp", k.host, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tcpSerial{conn: c}, nil
|
||||
}
|
||||
p, err := serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The modem lines are only touched when the operator asks for it. See
|
||||
// lowerLines: both defaults break somebody's station.
|
||||
if k.lowerLines {
|
||||
_ = p.SetDTR(false)
|
||||
_ = p.SetRTS(false)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// tcpSerial presents a TCP connection as a serial.Port, so the backend has one
|
||||
// code path whether the rig is on a wire or on the network.
|
||||
//
|
||||
// The modem-control methods are no-ops rather than errors: a bridge has no DTR
|
||||
// to raise, and failing them would break a caller that sets them defensively.
|
||||
type tcpSerial struct{ conn net.Conn }
|
||||
|
||||
func (t *tcpSerial) Read(p []byte) (int, error) {
|
||||
n, err := t.conn.Read(p)
|
||||
// A read deadline expiring is this transport's "no data yet", exactly what a
|
||||
// serial read timeout means to the caller — not a dead link. Reporting it as
|
||||
// an error would make ask() abandon a rig that is merely thinking.
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
func (t *tcpSerial) Write(p []byte) (int, error) { return t.conn.Write(p) }
|
||||
func (t *tcpSerial) Close() error { return t.conn.Close() }
|
||||
func (t *tcpSerial) SetReadTimeout(d time.Duration) error {
|
||||
if d <= 0 {
|
||||
return t.conn.SetReadDeadline(time.Time{})
|
||||
}
|
||||
return t.conn.SetReadDeadline(time.Now().Add(d))
|
||||
}
|
||||
func (t *tcpSerial) SetMode(*serial.Mode) error { return nil }
|
||||
func (t *tcpSerial) Drain() error { return nil }
|
||||
func (t *tcpSerial) ResetInputBuffer() error { return nil }
|
||||
func (t *tcpSerial) ResetOutputBuffer() error { return nil }
|
||||
func (t *tcpSerial) SetDTR(bool) error { return nil }
|
||||
func (t *tcpSerial) SetRTS(bool) error { return nil }
|
||||
func (t *tcpSerial) GetModemStatusBits() (*serial.ModemStatusBits, error) {
|
||||
return &serial.ModemStatusBits{}, nil
|
||||
}
|
||||
func (t *tcpSerial) Break(time.Duration) error { return nil }
|
||||
|
||||
// askVFO asks FR; or FT; and returns "A" or "B".
|
||||
//
|
||||
// The reply is FR0; / FR1; — the digit right after the two-letter command.
|
||||
// Anything else (a rig that answers with more fields, or not at all) returns
|
||||
// false, and the caller keeps whatever IF said rather than inventing a split.
|
||||
func (k *Kenwood) askVFO(cmd string) (string, bool) {
|
||||
r, err := k.ask(cmd)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
want := cmdPrefix(cmd)
|
||||
body := strings.TrimSuffix(strings.TrimPrefix(r, want), ";")
|
||||
if body == "" {
|
||||
return "", false
|
||||
}
|
||||
switch body[0] {
|
||||
case '0':
|
||||
return "A", true
|
||||
case '1':
|
||||
return "B", true
|
||||
}
|
||||
// 2 is "sub receiver" on a TS-2000 — real, but not a VFO we track. Saying
|
||||
// nothing is better than mapping it onto A or B and reporting a split that
|
||||
// does not exist.
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package cat
|
||||
|
||||
// CW keying through the Kenwood/Elecraft keyer — the KY command.
|
||||
//
|
||||
// Same idea as the Yaesu KY engine and the Icom / Flex keyers: the radio holds
|
||||
// the text and keys it with its own timing, so an Elecraft K3 (or any rig that
|
||||
// speaks this dialect) needs NO WinKeyer and NO second COM port — the single CAT
|
||||
// link does frequency, mode AND CW. That matters on a K3, whose one USB port is
|
||||
// the CAT port; a separate serial keyer would need a second cable OpsLog can't
|
||||
// give it.
|
||||
//
|
||||
// KY; → KYn; n=0 buffer has room, n=1 buffer full
|
||||
// KY <text>; queue up to 24 characters (the space after KY is part
|
||||
// of the command, not padding)
|
||||
// KS nnn; keyer speed in WPM (three digits)
|
||||
// RX; drop to receive — used to abort a send
|
||||
//
|
||||
// KY is the Elecraft-documented CW-over-CAT path on the K3/K4. A rig that refuses
|
||||
// it answers "?;", which SendCW turns into a stated reason rather than silence.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// kenwoodCWChunk is the most characters one KY command accepts.
|
||||
const kenwoodCWChunk = 24
|
||||
|
||||
// kenwoodCWAllowed is what the keyer can send; anything else is dropped, since an
|
||||
// unsupported byte can abort the buffer and lose the rest of the message.
|
||||
const kenwoodCWAllowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /?.,-=+:;()"
|
||||
|
||||
// SendCW queues a message on the rig's keyer, fed in 24-character pieces, waiting
|
||||
// for buffer room between pieces so a long macro doesn't lose its tail.
|
||||
func (k *Kenwood) SendCW(text string) error {
|
||||
msg := filterKenwoodCW(text)
|
||||
if msg == "" {
|
||||
return nil
|
||||
}
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
for len(msg) > 0 {
|
||||
n := kenwoodCWChunk
|
||||
if len(msg) < n {
|
||||
n = len(msg)
|
||||
}
|
||||
chunk := msg[:n]
|
||||
msg = msg[n:]
|
||||
k.waitCWBuffer(3 * time.Second)
|
||||
if err := k.write("KY " + chunk + ";"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := k.afterKY(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Pace the next piece by how long this one takes to key, so we never overrun
|
||||
// the 24-character buffer (the rig silently drops what doesn't fit).
|
||||
if len(msg) > 0 {
|
||||
time.Sleep(kenwoodCWDuration(chunk, k.keyerWPM()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// afterKY reads briefly after a KY write. An accepted KY says nothing; a REJECT
|
||||
// answers "?;". Reading it straight off the port (not through the shared rx
|
||||
// buffer) keeps that stray frame from being picked up by the next poll's ask —
|
||||
// which would mis-mark an unrelated command unsupported and desync the link — and
|
||||
// turns a silent non-transmission into a stated reason. The caller holds k.mu.
|
||||
func (k *Kenwood) afterKY() error {
|
||||
deadline := time.Now().Add(150 * time.Millisecond)
|
||||
tmp := make([]byte, 64)
|
||||
var buf []byte
|
||||
for time.Now().Before(deadline) {
|
||||
n, err := k.port.Read(tmp)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if n > 0 {
|
||||
buf = append(buf, tmp[:n]...)
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(buf), "?;") {
|
||||
return fmt.Errorf("this radio rejected CW over CAT (it answered \"?;\" to KY). " +
|
||||
"Switch the keyer engine to the serial-port keyer (DTR=CW) on a COM port instead")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitCWBuffer blocks until the keyer reports room, or the deadline passes. A rig
|
||||
// that never answers the KY; status query is not a reason to refuse to send — we
|
||||
// go ahead, and the per-chunk pacing covers the worst case. The caller holds k.mu.
|
||||
func (k *Kenwood) waitCWBuffer(within time.Duration) {
|
||||
deadline := time.Now().Add(within)
|
||||
for time.Now().Before(deadline) {
|
||||
if k.unsupported["KY"] {
|
||||
return // this rig doesn't report buffer state — pacing covers it
|
||||
}
|
||||
r, err := k.ask("KY;")
|
||||
if err != nil {
|
||||
return // unsupported / timeout — send anyway, pacing covers it
|
||||
}
|
||||
if !kenwoodCWBufferFull(r) {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// kenwoodCWBufferFull reads the KY; status reply. Deliberately asymmetric: only a
|
||||
// clear "1" after KY means full. Anything else reads as "go ahead" — refusing to
|
||||
// send because a status line was phrased unexpectedly is the worse failure.
|
||||
func kenwoodCWBufferFull(reply string) bool {
|
||||
r := strings.TrimSpace(reply)
|
||||
if !strings.HasPrefix(strings.ToUpper(r), "KY") {
|
||||
return false
|
||||
}
|
||||
for _, c := range r[2:] {
|
||||
switch c {
|
||||
case '0':
|
||||
return false
|
||||
case '1':
|
||||
return true
|
||||
case ' ', ';':
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StopCW aborts the message being sent. Kenwood documents no KY buffer-clear, so
|
||||
// this drops the transmitter — RX; forces receive, which is what an operator
|
||||
// pressing Escape wants; anything still queued is not keyed on the air.
|
||||
func (k *Kenwood) StopCW() error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
return k.write("RX;")
|
||||
}
|
||||
|
||||
// SetKeySpeed sets the keyer speed (WPM) via KS and remembers it for pacing.
|
||||
func (k *Kenwood) SetKeySpeed(wpm int) error {
|
||||
if wpm < 4 {
|
||||
wpm = 4
|
||||
}
|
||||
if wpm > 99 {
|
||||
wpm = 99 // KS is three digits but the K3 keyer tops out well below 100
|
||||
}
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
k.keyWPM = wpm
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
return k.write(fmt.Sprintf("KS%03d;", wpm))
|
||||
}
|
||||
|
||||
// keyerWPM is the speed to pace the buffer by. The caller holds k.mu.
|
||||
func (k *Kenwood) keyerWPM() int {
|
||||
if k.keyWPM >= 4 {
|
||||
return k.keyWPM
|
||||
}
|
||||
return 20
|
||||
}
|
||||
|
||||
// kenwoodCWDuration estimates how long a piece of text takes to key (PARIS
|
||||
// timing: a character averages 10 dits, a dit is 1.2/wpm seconds).
|
||||
func kenwoodCWDuration(text string, wpm int) time.Duration {
|
||||
if wpm < 4 {
|
||||
wpm = 20
|
||||
}
|
||||
ditMs := 1200.0 / float64(wpm)
|
||||
return time.Duration(float64(len(text))*10*ditMs) * time.Millisecond
|
||||
}
|
||||
|
||||
// filterKenwoodCW upper-cases and strips what the keyer cannot send; whitespace
|
||||
// becomes a single word gap.
|
||||
func filterKenwoodCW(text string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToUpper(text) {
|
||||
if r == '\t' || r == '\n' || r == '\r' {
|
||||
b.WriteByte(' ')
|
||||
continue
|
||||
}
|
||||
if strings.ContainsRune(kenwoodCWAllowed, r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// The Kenwood backend driven against a rig that answers, with no hardware.
|
||||
//
|
||||
// Nobody here owns a Kenwood, and a backend that has never completed a single
|
||||
// exchange is a guess however carefully it was written. But this repository
|
||||
// already contains the other half of the conversation: internal/catemu ANSWERS
|
||||
// this dialect, pretending to be a TS-2000 so an ACOM amplifier follows OpsLog.
|
||||
// The responder below replies exactly as catemu does — same FA/FB widths, same
|
||||
// 38-character IF layout, same ID019 — so the two halves are checked against
|
||||
// each other rather than against my reading of a manual.
|
||||
//
|
||||
// What this proves: the round trip completes, the model is identified, and
|
||||
// frequency, mode, VFO and split come back correct, including the second read
|
||||
// that split requires. What it cannot prove: that a real TS-590 answers on the
|
||||
// same timings, or that its firmware fills every IF field the way the
|
||||
// documentation says. Those still need a radio.
|
||||
|
||||
// fakeSerial is one end of an in-memory serial link. Only Read and Write carry
|
||||
// meaning; the rest satisfy the interface.
|
||||
type fakeSerial struct {
|
||||
mu sync.Mutex
|
||||
toRig *strings.Builder // what the backend has written
|
||||
fromRig []byte // what the rig has queued for the backend
|
||||
answer func(cmd string) string
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (f *fakeSerial) Write(p []byte) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.closed {
|
||||
return 0, fmt.Errorf("closed")
|
||||
}
|
||||
f.toRig.Write(p)
|
||||
// A rig answers as each terminated command arrives.
|
||||
for {
|
||||
s := f.toRig.String()
|
||||
i := strings.IndexByte(s, ';')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
cmd := s[:i+1]
|
||||
rest := s[i+1:]
|
||||
f.toRig.Reset()
|
||||
f.toRig.WriteString(rest)
|
||||
if r := f.answer(cmd); r != "" {
|
||||
f.fromRig = append(f.fromRig, r...)
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (f *fakeSerial) Read(p []byte) (int, error) {
|
||||
// A real port blocks until data or timeout; the backend polls, so returning
|
||||
// (0, nil) on an empty buffer models a read timeout with no bytes.
|
||||
for i := 0; i < 50; i++ {
|
||||
f.mu.Lock()
|
||||
if f.closed {
|
||||
f.mu.Unlock()
|
||||
return 0, fmt.Errorf("closed")
|
||||
}
|
||||
if len(f.fromRig) > 0 {
|
||||
n := copy(p, f.fromRig)
|
||||
f.fromRig = f.fromRig[n:]
|
||||
f.mu.Unlock()
|
||||
return n, nil
|
||||
}
|
||||
f.mu.Unlock()
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (f *fakeSerial) Close() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSerial) SetMode(*serial.Mode) error { return nil }
|
||||
func (f *fakeSerial) Drain() error { return nil }
|
||||
func (f *fakeSerial) ResetInputBuffer() error { return nil }
|
||||
func (f *fakeSerial) ResetOutputBuffer() error { return nil }
|
||||
func (f *fakeSerial) SetDTR(bool) error { return nil }
|
||||
func (f *fakeSerial) SetRTS(bool) error { return nil }
|
||||
func (f *fakeSerial) GetModemStatusBits() (*serial.ModemStatusBits, error) {
|
||||
return &serial.ModemStatusBits{}, nil
|
||||
}
|
||||
func (f *fakeSerial) SetReadTimeout(time.Duration) error { return nil }
|
||||
func (f *fakeSerial) Break(time.Duration) error { return nil }
|
||||
|
||||
// ts2000 answers as internal/catemu does. vfoA/vfoB are the two dials; mode is
|
||||
// the Kenwood digit; split selects which VFO transmits.
|
||||
type ts2000 struct {
|
||||
vfoA, vfoB int64
|
||||
mode byte
|
||||
onB bool
|
||||
split bool
|
||||
// lazyIF models a rig that answers FR/FT correctly but never fills IF's
|
||||
// split bit — the behaviour reported on a Flex through its Kenwood CAT
|
||||
// emulation, where the frequency reads perfectly and split never appears.
|
||||
lazyIF bool
|
||||
// noVFOCmds models a rig that rejects FR/FT outright ("?;").
|
||||
noVFOCmds bool
|
||||
seen []string
|
||||
}
|
||||
|
||||
func (r *ts2000) answer(cmd string) string {
|
||||
r.seen = append(r.seen, cmd)
|
||||
cur := r.vfoA
|
||||
vfoDigit := byte('0')
|
||||
if r.onB {
|
||||
cur, vfoDigit = r.vfoB, '1'
|
||||
}
|
||||
switch {
|
||||
case cmd == "ID;":
|
||||
return "ID019;" // TS-2000, exactly what catemu reports
|
||||
case cmd == "FA;":
|
||||
return fmt.Sprintf("FA%011d;", r.vfoA)
|
||||
case cmd == "FB;":
|
||||
return fmt.Sprintf("FB%011d;", r.vfoB)
|
||||
case cmd == "IF;":
|
||||
split := byte('0')
|
||||
if r.split && !r.lazyIF {
|
||||
split = '1'
|
||||
}
|
||||
// The catemu layout: IF | freq(11) | step(4) | RIT(±5) | 3 | mem(2) |
|
||||
// rx/tx | mode | VFO | scan | split | tone | tone#(2) | shift | ;
|
||||
return fmt.Sprintf("IF%011d%04d%+06d%03d%02d%01d%c%c%01d%c%01d%02d%01d;",
|
||||
cur, 0, 0, 0, 0, 0, r.mode, vfoDigit, 0, split, 0, 0, 0)
|
||||
case strings.HasPrefix(cmd, "FA") && len(cmd) > 3:
|
||||
fmt.Sscanf(cmd, "FA%d;", &r.vfoA)
|
||||
return ""
|
||||
case strings.HasPrefix(cmd, "FB") && len(cmd) > 3:
|
||||
fmt.Sscanf(cmd, "FB%d;", &r.vfoB)
|
||||
return ""
|
||||
case strings.HasPrefix(cmd, "MD") && len(cmd) == 4:
|
||||
r.mode = cmd[2]
|
||||
return ""
|
||||
case cmd == "FR;" || cmd == "FT;":
|
||||
if r.noVFOCmds {
|
||||
return "?;" // rejected, as a rig that does not know the command answers
|
||||
}
|
||||
// FR is the receive VFO, FT the transmit one. They differ exactly when
|
||||
// the rig is in split.
|
||||
v := vfoDigit
|
||||
if cmd == "FT;" && r.split {
|
||||
if vfoDigit == '0' {
|
||||
v = '1'
|
||||
} else {
|
||||
v = '0'
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s%c;", strings.TrimSuffix(cmd, ";"), v)
|
||||
}
|
||||
return "" // AI0;, TX;, RX; — set commands, no reply, as on a real rig
|
||||
}
|
||||
|
||||
func dialTo(rig *ts2000) func() (serial.Port, error) {
|
||||
return func() (serial.Port, error) {
|
||||
return &fakeSerial{toRig: &strings.Builder{}, answer: rig.answer}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestKenwoodAgainstEmulatedRig(t *testing.T) {
|
||||
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2'} // 20 m USB
|
||||
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||
k.dialPort = dialTo(rig)
|
||||
|
||||
if err := k.Connect(); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer k.Disconnect()
|
||||
if k.model != "TS-2000" {
|
||||
t.Errorf("model = %q, want TS-2000 (from ID019)", k.model)
|
||||
}
|
||||
|
||||
// Simplex on A.
|
||||
s, err := k.ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !s.Connected || s.FreqHz != 14250000 || s.Mode != "USB" || s.Vfo != "A" || s.Split {
|
||||
t.Errorf("simplex A gave %+v — want 14250000 USB on A, no split", s)
|
||||
}
|
||||
|
||||
// On B: everything must follow the VFO in use, the fault that took several
|
||||
// rounds to settle in the Yaesu backend.
|
||||
rig.onB = true
|
||||
if s, err = k.ReadState(); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if s.FreqHz != 14260000 || s.Vfo != "B" {
|
||||
t.Errorf("on B gave %d on VFO %s — want 14260000 on B", s.FreqHz, s.Vfo)
|
||||
}
|
||||
|
||||
// Tuning must write to the VFO in use, not blindly to FA.
|
||||
if err := k.SetFrequency(14265000); err != nil {
|
||||
t.Fatalf("set freq: %v", err)
|
||||
}
|
||||
if rig.vfoB != 14265000 {
|
||||
t.Errorf("VFO B = %d, want 14265000 — the write went to the wrong VFO", rig.vfoB)
|
||||
}
|
||||
if rig.vfoA != 14250000 {
|
||||
t.Errorf("VFO A was disturbed: %d", rig.vfoA)
|
||||
}
|
||||
|
||||
// Split: receive on B, transmit on A. ADIF says FREQ is the TRANSMIT
|
||||
// frequency, so the two must not be swapped — a mistake that writes the
|
||||
// wrong frequency into every logged QSO.
|
||||
rig.split = true
|
||||
if s, err = k.ReadState(); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !s.Split || s.FreqHz != 14250000 || s.RxFreqHz != 14265000 {
|
||||
t.Errorf("split gave tx=%d rx=%d split=%v — want tx 14250000 (A), rx 14265000 (B)",
|
||||
s.FreqHz, s.RxFreqHz, s.Split)
|
||||
}
|
||||
|
||||
// Mode: CW below 10 MHz stays CW; the sideband convention only governs SSB.
|
||||
rig.split = false
|
||||
if err := k.SetMode("CW"); err != nil {
|
||||
t.Fatalf("set mode: %v", err)
|
||||
}
|
||||
if rig.mode != '3' {
|
||||
t.Errorf("mode digit = %q, want '3' (CW)", rig.mode)
|
||||
}
|
||||
|
||||
// PTT is a bare command, and the rig must have actually seen it.
|
||||
if err := k.SetPTT(true); err != nil {
|
||||
t.Fatalf("ptt: %v", err)
|
||||
}
|
||||
if err := k.SetPTT(false); err != nil {
|
||||
t.Fatalf("ptt off: %v", err)
|
||||
}
|
||||
seen := strings.Join(rig.seen, " ")
|
||||
for _, want := range []string{"AI0;", "TX;", "RX;"} {
|
||||
if !strings.Contains(seen, want) {
|
||||
t.Errorf("the rig never received %s — sent: %s", want, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A port that opens onto silence must be reported as such, not as a connected
|
||||
// radio. A powered-off rig logged as "connected" wasted an evening of a user's
|
||||
// time on the Yaesu backend.
|
||||
func TestKenwoodSilentRigIsNotConnected(t *testing.T) {
|
||||
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||
k.dialPort = func() (serial.Port, error) {
|
||||
return &fakeSerial{toRig: &strings.Builder{}, answer: func(string) string { return "" }}, nil
|
||||
}
|
||||
err := k.Connect()
|
||||
if err == nil {
|
||||
t.Fatal("a silent port was reported as a connected rig")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "sent nothing") {
|
||||
t.Errorf("error was %q — a silent port should be reported as silence", err)
|
||||
}
|
||||
if k.model != "" {
|
||||
t.Errorf("a stale model survived a failed connect: %q", k.model)
|
||||
}
|
||||
}
|
||||
|
||||
// Split found through FR/FT when the rig never fills IF's split bit.
|
||||
//
|
||||
// Reported on a Flex through its Kenwood CAT emulation: frequency read
|
||||
// perfectly, split never appeared. Rather than guess at which IF column that
|
||||
// firmware populates, ask the question that DEFINES split — is the transmit VFO
|
||||
// a different VFO from the receive one — which is what FR and FT answer, and
|
||||
// the same rule the Yaesu backend settled on after several wrong turns.
|
||||
func TestKenwoodSplitFromFRFT(t *testing.T) {
|
||||
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2', lazyIF: true}
|
||||
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||
k.dialPort = dialTo(rig)
|
||||
if err := k.Connect(); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer k.Disconnect()
|
||||
|
||||
// Simplex: FR and FT agree, and nothing may be invented from that.
|
||||
s, err := k.ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if s.Split {
|
||||
t.Errorf("split reported while FR and FT agree: tx=%d rx=%d", s.FreqHz, s.RxFreqHz)
|
||||
}
|
||||
|
||||
// Split on, IF still silent about it: receive on A, transmit on B.
|
||||
rig.split = true
|
||||
// The FR/FT split re-check is throttled to once a second (it tripled the poll
|
||||
// time on a slow K3), so back-to-back reads reuse the last result. Simulate the
|
||||
// recheck window having elapsed — in the field the 250 ms poll covers it inside
|
||||
// a second.
|
||||
k.splitCheckAt = time.Time{}
|
||||
if s, err = k.ReadState(); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !s.Split {
|
||||
t.Fatal("split not detected — FR/FT disagreed and IF's bit was empty, which is the reported case")
|
||||
}
|
||||
if s.FreqHz != 14260000 || s.RxFreqHz != 14250000 {
|
||||
t.Errorf("tx=%d rx=%d — want tx 14260000 (B), rx 14250000 (A)", s.FreqHz, s.RxFreqHz)
|
||||
}
|
||||
}
|
||||
|
||||
// A rig that rejects FR/FT is asked once, then left alone — and its IF split
|
||||
// bit still works. The fallback must not cost a timeout on every poll, nor
|
||||
// break the rigs that were already fine.
|
||||
func TestKenwoodSplitWhenFRFTRejected(t *testing.T) {
|
||||
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2', noVFOCmds: true}
|
||||
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||
k.dialPort = dialTo(rig)
|
||||
if err := k.Connect(); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer k.Disconnect()
|
||||
|
||||
if _, err := k.ReadState(); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
asked := 0
|
||||
for _, c := range rig.seen {
|
||||
if c == "FR;" || c == "FT;" {
|
||||
asked++
|
||||
}
|
||||
}
|
||||
if _, err := k.ReadState(); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
after := 0
|
||||
for _, c := range rig.seen {
|
||||
if c == "FR;" || c == "FT;" {
|
||||
after++
|
||||
}
|
||||
}
|
||||
if after != asked {
|
||||
t.Errorf("a rejected command was asked again: %d then %d", asked, after)
|
||||
}
|
||||
|
||||
// IF's own split bit still drives the result on such a rig.
|
||||
rig.split = true
|
||||
s, err := k.ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !s.Split || s.FreqHz != 14260000 || s.RxFreqHz != 14250000 {
|
||||
t.Errorf("IF-reported split broke: split=%v tx=%d rx=%d", s.Split, s.FreqHz, s.RxFreqHz)
|
||||
}
|
||||
}
|
||||
|
||||
// A rig that talks but never answers what was asked must not be reported as a
|
||||
// silent one.
|
||||
//
|
||||
// The two faults need opposite responses: silence means the radio is off, the
|
||||
// wrong port, or a dead cable; noise means the baud rate is wrong or something
|
||||
// is echoing the line. "The rig is not answering" sent an operator checking the
|
||||
// power switch on a radio whose frames were visibly arriving.
|
||||
func TestKenwoodNoisyRigIsReportedAsNoiseNotSilence(t *testing.T) {
|
||||
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||
k.dialPort = func() (serial.Port, error) {
|
||||
return &fakeSerial{toRig: &strings.Builder{}, answer: func(cmd string) string {
|
||||
return "XX9999;" // something, but never the reply to ID; or IF;
|
||||
}}, nil
|
||||
}
|
||||
err := k.Connect()
|
||||
if err == nil {
|
||||
t.Fatal("a rig answering gibberish was reported as connected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "sending data") || !strings.Contains(err.Error(), "XX9999;") {
|
||||
t.Errorf("error was %q — it should say data arrived, and quote it", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package cat
|
||||
|
||||
import "testing"
|
||||
|
||||
// The IF status frame, read by POSITION.
|
||||
//
|
||||
// One frame carries frequency, TX state, mode, VFO and split, which is why it is
|
||||
// the poll. Every field is a fixed offset, so a length check comes first: a
|
||||
// truncated read would otherwise index into the wrong characters and yield a
|
||||
// plausible-looking wrong frequency — the worst kind, because it reaches the log.
|
||||
//
|
||||
// The layout is the one internal/catemu already EMITS to satisfy an ACOM
|
||||
// amplifier, so these two halves of the repository agree by construction.
|
||||
func TestParseKenwoodIF(t *testing.T) {
|
||||
// IF | freq(11) | step(4) | RIT(±5) | 3 | mem(2) | rx/tx | mode | VFO | scan |
|
||||
// split | tone | tone#(2) | shift | ; → 38 characters
|
||||
const rx20mUSB = "IF00014250000" + "0000" + "+00000" + "000" + "00" + "0" + "2" + "0" + "0" + "0" + "0" + "00" + "0" + ";"
|
||||
if len(rx20mUSB) != 38 {
|
||||
t.Fatalf("the test's own frame is %d chars, not 38 — fix the fixture first", len(rx20mUSB))
|
||||
}
|
||||
|
||||
f, ok := parseKenwoodIF(rx20mUSB)
|
||||
if !ok {
|
||||
t.Fatalf("a valid frame was rejected: %q", rx20mUSB)
|
||||
}
|
||||
if f.FreqHz != 14250000 {
|
||||
t.Errorf("frequency = %d, want 14250000", f.FreqHz)
|
||||
}
|
||||
if f.Mode != '2' {
|
||||
t.Errorf("mode = %q, want '2' (USB)", f.Mode)
|
||||
}
|
||||
if f.VFO != "A" || f.Split || f.TX {
|
||||
t.Errorf("got VFO=%s split=%v tx=%v — want A, simplex, receiving", f.VFO, f.Split, f.TX)
|
||||
}
|
||||
|
||||
// Same frame transmitting, on VFO B, split, in CW.
|
||||
const txSplitB = "IF00007030000" + "0000" + "+00000" + "000" + "00" + "1" + "3" + "1" + "0" + "1" + "0" + "00" + "0" + ";"
|
||||
f2, ok := parseKenwoodIF(txSplitB)
|
||||
if !ok {
|
||||
t.Fatalf("valid frame rejected: %q", txSplitB)
|
||||
}
|
||||
if !f2.TX || f2.Mode != '3' || f2.VFO != "B" || !f2.Split {
|
||||
t.Errorf("got tx=%v mode=%q vfo=%s split=%v — want transmitting, CW, B, split",
|
||||
f2.TX, f2.Mode, f2.VFO, f2.Split)
|
||||
}
|
||||
|
||||
// Rejections: anything that would make position-reading unsafe.
|
||||
for _, bad := range []string{
|
||||
"",
|
||||
"IF;", // query echoed with no payload
|
||||
"IF0001425000;", // short — the trap this guards against
|
||||
"FA00014250000;", // another command's reply
|
||||
"IF0001425000x0000+00000000000203000000;", // non-numeric frequency
|
||||
} {
|
||||
if _, ok := parseKenwoodIF(bad); ok {
|
||||
t.Errorf("accepted a frame it should have refused: %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FA/FB carry ELEVEN digits on Kenwood where Yaesu uses nine — the single most
|
||||
// likely place to copy the Yaesu backend and be wrong by a factor of a hundred.
|
||||
func TestParseKenwoodFreq(t *testing.T) {
|
||||
cases := []struct {
|
||||
reply, prefix string
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{"FA00014250000;", "FA", 14250000, true},
|
||||
{"FB00007030000;", "FB", 7030000, true},
|
||||
{"FA00000474000;", "FA", 474000, true}, // 630 m — leading zeros must not truncate
|
||||
{"FA10368000000;", "FA", 10368000000, true}, // 3 cm
|
||||
{"FA00014250000;", "FB", 0, false}, // wrong VFO is never silently accepted
|
||||
{"FA;", "FA", 0, false},
|
||||
{"FAxxxxxxxxxxx;", "FA", 0, false},
|
||||
{"", "FA", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := parseKenwoodFreq(c.reply, c.prefix)
|
||||
if got != c.want || ok != c.ok {
|
||||
t.Errorf("parseKenwoodFreq(%q,%q) = %d,%v — want %d,%v", c.reply, c.prefix, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The sideband follows the frequency by worldwide convention. A backend that
|
||||
// puts USB on 40 m makes every SSB QSO in the log wrong, which is why this is
|
||||
// pinned rather than left to the rig.
|
||||
func TestKenwoodModeDigit(t *testing.T) {
|
||||
cases := []struct {
|
||||
mode string
|
||||
hz int64
|
||||
want byte
|
||||
}{
|
||||
{"SSB", 7150000, '1'}, // LSB below 10 MHz
|
||||
{"SSB", 14250000, '2'}, // USB above
|
||||
{"LSB", 14250000, '1'}, // an explicit choice wins over the convention
|
||||
{"USB", 7150000, '2'},
|
||||
{"CW", 7030000, '3'},
|
||||
{"RTTY", 14080000, '6'},
|
||||
{"AM", 7150000, '5'},
|
||||
{"FM", 145000000, '4'},
|
||||
{"FT8", 7074000, '2'}, // data is ALWAYS USB, even below 10 MHz (K3 "DATA REV" otherwise)
|
||||
{"FT8", 14074000, '2'}, // …and above
|
||||
{"", 14074000, 0}, // nothing to set
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := kenwoodModeDigit(c.mode, c.hz); got != c.want {
|
||||
t.Errorf("kenwoodModeDigit(%q, %d) = %q, want %q", c.mode, c.hz, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The DATA-mode preference only applies to soundcard/data modes, never to voice,
|
||||
// CW or native RTTY — getting this wrong would hijack an SSB or CW mode change.
|
||||
func TestIsKenwoodDataMode(t *testing.T) {
|
||||
data := []string{"FT8", "FT4", "PSK31", "JT65", "DATA", "DIGITAL", "OLIVIA"}
|
||||
notData := []string{"", "LSB", "USB", "SSB", "CW", "CW-R", "FM", "AM", "RTTY", "FSK", "RTTY-R"}
|
||||
for _, m := range data {
|
||||
if !isKenwoodDataMode(m) {
|
||||
t.Errorf("isKenwoodDataMode(%q) = false, want true", m)
|
||||
}
|
||||
}
|
||||
for _, m := range notData {
|
||||
if isKenwoodDataMode(m) {
|
||||
t.Errorf("isKenwoodDataMode(%q) = true, want false", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What the log records for each mode digit the rig reports.
|
||||
func TestKenwoodModeToADIF(t *testing.T) {
|
||||
cases := []struct {
|
||||
d byte
|
||||
want string
|
||||
}{
|
||||
{'1', "LSB"}, {'2', "USB"},
|
||||
{'3', "CW"}, {'7', "CW"}, // CW-R is still CW in the log
|
||||
{'4', "FM"}, {'5', "AM"},
|
||||
{'6', "RTTY"}, {'9', "RTTY"}, // FSK-R likewise
|
||||
{'0', ""}, // unknown → say nothing rather than guess
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := kenwoodModeToADIF(c.d, "FT8"); got != c.want {
|
||||
t.Errorf("kenwoodModeToADIF(%q) = %q, want %q", c.d, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// LogSink, when set by the host app at startup, receives every CAT debug
|
||||
@@ -73,3 +74,54 @@ func DebugLogPath() string {
|
||||
}
|
||||
return filepath.Join(base, "OpsLog", "cat.log")
|
||||
}
|
||||
|
||||
// ── CAT wire trace ─────────────────────────────────────────
|
||||
//
|
||||
// Opt-in, off by default. Turning it on logs every frame sent and received —
|
||||
// CI-V as hex, the ASCII backends (Kenwood, Yaesu) as the text they exchange.
|
||||
//
|
||||
// It exists because a fault nobody could reason about — "the keyer sends one
|
||||
// element then stalls" — was settled in one line the moment the actual bytes
|
||||
// were visible, and the CAT links have since produced the same class of report:
|
||||
// a MOX button that sets split instead of transmitting, and a split the Kenwood
|
||||
// backend does not recognise on a rig whose frequency it reads perfectly.
|
||||
//
|
||||
// Both are questions about what the RIG actually said. Guessing at that from a
|
||||
// protocol document has already cost this project a wrong fix that broke the
|
||||
// case which already worked.
|
||||
var catTrace atomic.Bool
|
||||
|
||||
// SetCIVTrace turns the CAT wire trace on or off. Named for the CI-V link it
|
||||
// was written for; it now covers the text backends too.
|
||||
func SetCIVTrace(on bool) {
|
||||
catTrace.Store(on)
|
||||
if on {
|
||||
debugLog.Printf("wire trace ON — every CAT frame to and from the rig is logged")
|
||||
} else {
|
||||
debugLog.Printf("wire trace OFF")
|
||||
}
|
||||
}
|
||||
|
||||
// CIVTraceEnabled reports whether the trace is running (for the settings UI).
|
||||
func CIVTraceEnabled() bool { return catTrace.Load() }
|
||||
|
||||
// traceCIV logs one CI-V frame. dir is "TX" (to the rig) or "RX" (from it).
|
||||
func traceCIV(dir string, b []byte) {
|
||||
if !catTrace.Load() || len(b) == 0 {
|
||||
return
|
||||
}
|
||||
debugLog.Printf("civ %s % X", dir, b)
|
||||
}
|
||||
|
||||
// traceText logs one ASCII exchange, for the Kenwood and Yaesu backends.
|
||||
//
|
||||
// The text is QUOTED. A truncated or empty reply must be visible as such
|
||||
// rather than vanish into the line: "" and ";" look identical unquoted, and
|
||||
// telling them apart is the whole question when a rig answers a command it
|
||||
// does not really support.
|
||||
func traceText(backend, dir, s string) {
|
||||
if !catTrace.Load() || s == "" {
|
||||
return
|
||||
}
|
||||
debugLog.Printf("%s %s %q", backend, dir, s)
|
||||
}
|
||||
|
||||
+71
-6
@@ -28,6 +28,12 @@ type TCI struct {
|
||||
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
|
||||
spotsEnabled bool // mirror cluster spots onto the TCI panorama
|
||||
|
||||
// OnSpotClick is called when the user clicks one of our spots on the TCI
|
||||
// panorama (callsign + freq), so the host can fill the entry form. Set before
|
||||
// Connect. Mirrors the FlexRadio panadapter-click flow.
|
||||
OnSpotClick func(callsign string, freqHz int64)
|
||||
unhandledSeen map[string]bool // log each unknown TCI message type once
|
||||
|
||||
mu sync.Mutex // guards conn + writes + state
|
||||
conn *websocket.Conn
|
||||
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
|
||||
@@ -110,15 +116,32 @@ func (t *TCI) SendSpot(s SpotInfo) error {
|
||||
if call == "" || s.FreqHz <= 0 {
|
||||
return nil
|
||||
}
|
||||
color := strings.TrimSpace(s.Color)
|
||||
if color == "" {
|
||||
color = "#FFFFA500" // opaque orange default
|
||||
// TCI's SPOT command wants the colour as a signed 32-bit DECIMAL integer in
|
||||
// 0xAARRGGBB order — NOT a "0x…" hex string (e.g. "spot:UN7GK,cw,14025000,
|
||||
// -16776961,test;"). ExpertSDR silently drops a spot whose colour field it
|
||||
// can't parse as a number, which is why spots never showed on the panorama
|
||||
// while tuning (a separate command) still worked.
|
||||
hex := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(s.Color), "#"), "0x")
|
||||
if hex == "" {
|
||||
hex = "FFFFA500" // opaque orange default
|
||||
}
|
||||
if len(hex) == 6 {
|
||||
hex = "FF" + hex // add full-opacity alpha when only RGB was supplied
|
||||
}
|
||||
argb, err := strconv.ParseUint(hex, 16, 32)
|
||||
if err != nil {
|
||||
argb = 0xFFFFA500
|
||||
}
|
||||
// Use a valid TCI modulation (usb/lsb/cw/digl…) so ExpertSDR accepts the spot;
|
||||
// fall back to the raw label if we can't map it. The click-to-tune path already
|
||||
// maps the mode separately, so this only affects the spot's displayed mode.
|
||||
mode := adifToTCIMode(s.Mode, s.FreqHz)
|
||||
if mode == "" {
|
||||
mode = strings.ToLower(strings.TrimSpace(s.Mode))
|
||||
}
|
||||
color = "0x" + strings.TrimPrefix(color, "#")
|
||||
mode := strings.ToLower(strings.TrimSpace(s.Mode))
|
||||
// Commas/semicolons would break TCI's comma-separated argument parsing.
|
||||
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
|
||||
return t.send(fmt.Sprintf("spot:%s,%s,%d,%s,%s;", call, mode, s.FreqHz, color, text))
|
||||
return t.send(fmt.Sprintf("spot:%s,%s,%d,%d,%s;", call, mode, s.FreqHz, int32(argb), text))
|
||||
}
|
||||
|
||||
// Disconnect closes the WebSocket; the reader goroutine then exits.
|
||||
@@ -294,6 +317,48 @@ func (t *TCI) handle(msg string) {
|
||||
if get(0) == "0" {
|
||||
t.tx = get(1) == "true"
|
||||
}
|
||||
default:
|
||||
lname := strings.ToLower(name)
|
||||
// A click on one of our panorama spots comes back as
|
||||
// CLICKED_ON_SPOT:<call>,<hz> (legacy)
|
||||
// RX_CLICKED_ON_SPOT:<rx>,<ch>,<call>,<hz>
|
||||
// Neither name starts with "spot", which is why the click was silently
|
||||
// ignored before. Read the callsign (the one non-numeric field) and the
|
||||
// frequency (the large numeric field) positionally-independently, so both
|
||||
// shapes work without depending on the exact arg order.
|
||||
if strings.Contains(lname, "spot") {
|
||||
var call string
|
||||
var hz int64
|
||||
for _, raw := range f {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
if n >= 10000 { // a real frequency, not an rx/channel index
|
||||
hz = n
|
||||
}
|
||||
} else if call == "" {
|
||||
call = strings.ToUpper(v) // callsigns always carry letters
|
||||
}
|
||||
}
|
||||
debugLog.Printf("TCI: spot click %q → call=%s freq=%d", msg, call, hz)
|
||||
if call != "" && t.OnSpotClick != nil {
|
||||
cb := t.OnSpotClick
|
||||
go cb(call, hz)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Log every OTHER unknown message TYPE once, so the protocol (incl. any
|
||||
// spot-click notification named differently) is discoverable from the log
|
||||
// without flooding it with the frequent streamed messages.
|
||||
if t.unhandledSeen == nil {
|
||||
t.unhandledSeen = map[string]bool{}
|
||||
}
|
||||
if !t.unhandledSeen[lname] {
|
||||
t.unhandledSeen[lname] = true
|
||||
debugLog.Printf("TCI: (unhandled once) %s", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+40
-2
@@ -56,6 +56,9 @@ type Xiegu struct {
|
||||
// asking every cycle — a Xiegu that has no split must not cost a timeout per
|
||||
// poll, which would slow the whole loop to a crawl.
|
||||
splitSupported bool
|
||||
// pttLine is "", "rts" or "dtr": which hardware line keys the rig, when the
|
||||
// CI-V command does not.
|
||||
pttLine string
|
||||
}
|
||||
|
||||
func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu {
|
||||
@@ -74,6 +77,14 @@ func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu {
|
||||
}
|
||||
}
|
||||
|
||||
// SetPTTLine selects the hardware line that keys this rig: "rts", "dtr", or ""
|
||||
// for the CI-V command. Set before Connect.
|
||||
func (x *Xiegu) SetPTTLine(line string) {
|
||||
x.mu.Lock()
|
||||
x.pttLine = strings.ToLower(strings.TrimSpace(line))
|
||||
x.mu.Unlock()
|
||||
}
|
||||
|
||||
func (x *Xiegu) Name() string { return "xiegu" }
|
||||
|
||||
func (x *Xiegu) Connect() error {
|
||||
@@ -87,6 +98,20 @@ func (x *Xiegu) Connect() error {
|
||||
return fmt.Errorf("xiegu: open %s @ %d baud: %w", x.portName, x.baud, err)
|
||||
}
|
||||
p.SetReadTimeout(200 * time.Millisecond)
|
||||
// Deassert DTR and RTS.
|
||||
//
|
||||
// Windows raises both when a serial port is opened, and a great many
|
||||
// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into
|
||||
// transmit the moment OpsLog connects and STAYS there — Xiegu's own
|
||||
// documentation asks for RTS and DTR low. The same applies to an Icom with
|
||||
// USB SEND mapped to a line (which is why the CI-V backend has done this
|
||||
// from the start) and to any rig keyed by a home-made cable.
|
||||
//
|
||||
// PTT on this backend is a CAT command, so neither line should ever be
|
||||
// asserted here. A station keying by RTS/DTR configures that separately,
|
||||
// on its own port.
|
||||
_ = p.SetDTR(false)
|
||||
_ = p.SetRTS(false)
|
||||
x.port = p
|
||||
x.splitSupported = true
|
||||
|
||||
@@ -177,12 +202,25 @@ func (x *Xiegu) SetMode(mode string) error {
|
||||
return x.send(civ.CmdSetMode, m)
|
||||
}
|
||||
|
||||
// SetPTT keys the transmitter, by CI-V or by a hardware line.
|
||||
//
|
||||
// A G90 does not transmit on the CI-V PTT command. Xiegu's own interfaces key
|
||||
// on RTS or DTR instead — the DE-19 does exactly that — which is why the line
|
||||
// can be selected here. Without it, WSJT-X talking to OpsLog's rigctld server
|
||||
// decoded perfectly and never transmitted: the command left, the radio ignored
|
||||
// it, and nothing in the chain was wrong enough to complain.
|
||||
func (x *Xiegu) SetPTT(on bool) error {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
if x.port == nil {
|
||||
return fmt.Errorf("xiegu: not connected")
|
||||
}
|
||||
switch x.pttLine {
|
||||
case "rts":
|
||||
return x.port.SetRTS(on)
|
||||
case "dtr":
|
||||
return x.port.SetDTR(on)
|
||||
}
|
||||
v := byte(0x00)
|
||||
if on {
|
||||
v = 0x01
|
||||
@@ -247,8 +285,8 @@ func (x *Xiegu) readFreq() (int64, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
hz := civ.BCDToFreq(d.Data)
|
||||
if hz <= 0 {
|
||||
hz, ok := civ.BCDToFreq(d.Data)
|
||||
if !ok || hz <= 0 {
|
||||
return 0, fmt.Errorf("xiegu: unusable frequency % X", d.Data)
|
||||
}
|
||||
return hz, nil
|
||||
|
||||
@@ -76,8 +76,9 @@ func TestXieguFrequencyEncoding(t *testing.T) {
|
||||
if len(b) != 5 {
|
||||
t.Fatalf("FreqToBCD(%d) produced %d bytes, want 5", hz, len(b))
|
||||
}
|
||||
if got := civ.BCDToFreq(b); got != hz {
|
||||
t.Errorf("round trip %d → % X → %d", hz, b, got)
|
||||
got, ok := civ.BCDToFreq(b)
|
||||
if !ok || got != hz {
|
||||
t.Errorf("round trip %d → % X → %d (ok=%v)", hz, b, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
-6
@@ -17,7 +17,7 @@ package cat
|
||||
//
|
||||
// FA; → FA014074000; VFO A frequency, 9 digits, Hz
|
||||
// FB; → FB014100000; VFO B frequency
|
||||
// MD0; → MD02; operating mode of the main receiver
|
||||
// MD0;/MD1; → MD02; operating mode — 0 = main receiver, 1 = sub
|
||||
// FR; → FR1; RECEIVE VFO (0=main/A, 1=sub/B)
|
||||
// VS; → VS0; selected VFO, on models without FR
|
||||
// ST; → ST1; split (FTDX10/FTDX101)
|
||||
@@ -82,9 +82,12 @@ var yaesuModeToADIF = map[byte]string{
|
||||
}
|
||||
|
||||
type Yaesu struct {
|
||||
portName string
|
||||
baud int
|
||||
digital string // ADIF mode reported for DATA (FT8, RTTY…)
|
||||
// lowerLines deasserts DTR and RTS on connect — see Kenwood.lowerLines for
|
||||
// why neither default is safe for every station.
|
||||
lowerLines bool
|
||||
portName string
|
||||
baud int
|
||||
digital string // ADIF mode reported for DATA (FT8, RTTY…)
|
||||
|
||||
mu sync.Mutex
|
||||
port serial.Port
|
||||
@@ -127,6 +130,14 @@ func NewYaesu(portName string, baud int, digital string) *Yaesu {
|
||||
return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
|
||||
}
|
||||
|
||||
// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
|
||||
// before Connect.
|
||||
func (y *Yaesu) SetLowerLines(v bool) {
|
||||
y.mu.Lock()
|
||||
y.lowerLines = v
|
||||
y.mu.Unlock()
|
||||
}
|
||||
|
||||
func (y *Yaesu) Name() string { return "yaesu" }
|
||||
|
||||
func (y *Yaesu) Connect() error {
|
||||
@@ -150,6 +161,12 @@ func (y *Yaesu) Connect() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
|
||||
}
|
||||
// The modem lines are only touched when the operator asks for it — see the
|
||||
// note on Kenwood.lowerLines. Both defaults break somebody's station.
|
||||
if y.lowerLines {
|
||||
_ = p.SetDTR(false)
|
||||
_ = p.SetRTS(false)
|
||||
}
|
||||
p.SetReadTimeout(300 * time.Millisecond)
|
||||
y.port = p
|
||||
|
||||
@@ -304,7 +321,7 @@ func (y *Yaesu) ReadState() (RigState, error) {
|
||||
y.curRXFreq = s.RxFreqHz
|
||||
}
|
||||
|
||||
if r, err := y.ask("MD0;"); err == nil && len(r) >= 4 {
|
||||
if r, err := y.ask("MD" + y.modeVFOSuffix() + ";"); err == nil && len(r) >= 4 {
|
||||
// Keep the RAW mode too: ADIF folds CW-U/CW-L and DATA-U/DATA-L together,
|
||||
// but the panel has to show which sideband the rig is actually on.
|
||||
y.panel.RawMode = yaesuRawModeName(r[3])
|
||||
@@ -351,7 +368,25 @@ func (y *Yaesu) SetMode(mode string) error {
|
||||
if d == 0 {
|
||||
return fmt.Errorf("yaesu: no CAT mode for %q", mode)
|
||||
}
|
||||
return y.write(fmt.Sprintf("MD0%c;", d))
|
||||
return y.write(fmt.Sprintf("MD%s%c;", y.modeVFOSuffix(), d))
|
||||
}
|
||||
|
||||
// modeVFOSuffix picks which receiver the mode commands address: "0" is main,
|
||||
// "1" is sub.
|
||||
//
|
||||
// Both the read and the write used 0 unconditionally. A spot click already tuned
|
||||
// the right VFO — that part was fixed — but then set the MODE on main, so an
|
||||
// operator working from the sub receiver had a spot change the mode of the VFO
|
||||
// they were not using and leave the one they were on untouched (F4NBZ,
|
||||
// 2026-07-29). The rig reads the same way, so the panel showed main's mode too.
|
||||
//
|
||||
// Rigs without a sub receiver simply never see "1": curVFO only becomes B on a
|
||||
// rig that reports its receive VFO.
|
||||
func (y *Yaesu) modeVFOSuffix() string {
|
||||
if y.curVFO == "B" {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetPTT(on bool) error {
|
||||
|
||||
@@ -430,7 +430,9 @@ func (y *Yaesu) SetYaesuModeRaw(name string) error {
|
||||
if !ok {
|
||||
return fmt.Errorf("yaesu: unknown rig mode %q", name)
|
||||
}
|
||||
return y.setAndRefresh(fmt.Sprintf("MD0%c;", d))
|
||||
// Same receiver as everywhere else — the console must not set main's mode
|
||||
// while the operator is working the sub VFO.
|
||||
return y.setAndRefresh(fmt.Sprintf("MD%s%c;", y.modeVFOSuffix(), d))
|
||||
}
|
||||
|
||||
// yaesuRawModeDigit maps a rig-mode name to its MD digit.
|
||||
|
||||
@@ -347,3 +347,36 @@ func TestYaesuSplitProbeOrder(t *testing.T) {
|
||||
t.Error("ST1 is a flag and reads as split whatever the VFO — that is the trap")
|
||||
}
|
||||
}
|
||||
|
||||
// The mode commands address the receiver the operator is ON.
|
||||
//
|
||||
// Reported on an FTDX101 (F4NBZ, 2026-07-29): once a spot click tuned the right
|
||||
// VFO, it still changed the mode of MAIN while the operator worked from SUB — so
|
||||
// the VFO in use kept its old mode and the idle one was altered. The read had the
|
||||
// same fault, so the panel showed main's mode as well.
|
||||
func TestYaesuModeVFOSuffix(t *testing.T) {
|
||||
cases := []struct{ vfo, want string }{
|
||||
{"A", "0"}, // main
|
||||
{"", "0"}, // not yet read — main is the safe assumption
|
||||
{"B", "1"}, // sub
|
||||
{"AB", "0"}, // pair enums start with the receive VFO
|
||||
}
|
||||
for _, c := range cases {
|
||||
y := &Yaesu{}
|
||||
y.curVFO = c.vfo
|
||||
if got := y.modeVFOSuffix(); got != c.want {
|
||||
t.Errorf("curVFO=%q → MD%s, want MD%s", c.vfo, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Spelled out as the commands actually sent, which is what the rig sees.
|
||||
y := &Yaesu{}
|
||||
y.curVFO = "B"
|
||||
if cmd := "MD" + y.modeVFOSuffix() + "3;"; cmd != "MD13;" {
|
||||
t.Errorf("setting CW on the sub receiver sends %q, want MD13;", cmd)
|
||||
}
|
||||
y.curVFO = "A"
|
||||
if cmd := "MD" + y.modeVFOSuffix() + "3;"; cmd != "MD03;" {
|
||||
t.Errorf("setting CW on main sends %q, want MD03;", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ package cluster
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
@@ -346,12 +347,29 @@ func (s *session) run() {
|
||||
}
|
||||
}
|
||||
|
||||
// idleTick is how often the read is interrupted so the loop can notice a stop
|
||||
// request. It is NOT a liveness test — see the read loop.
|
||||
//
|
||||
// A var, not a const, so the test that proves a silent node survives can shorten
|
||||
// it: at 30 s that test spends a minute doing nothing, and a slow test is a test
|
||||
// that gets skipped.
|
||||
var idleTick = 30 * time.Second
|
||||
|
||||
// quietNotice is the silence after which the log says so, once.
|
||||
const quietNotice = 10 * time.Minute
|
||||
|
||||
// runOnce dials, optionally logs in, sends init commands, parses spots.
|
||||
// Returns the moment we marked the link "connected" (zero if dial failed)
|
||||
// and the error that ended the session (nil if stopCh).
|
||||
func (s *session) runOnce() (time.Time, error) {
|
||||
addr := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port)) // IPv6-safe
|
||||
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||
// KeepAlive is set explicitly rather than left to the package default: it is
|
||||
// what actually detects a dead peer here, so it should be visible in the
|
||||
// code that depends on it. The OS probes an idle connection and a genuine
|
||||
// failure surfaces as an error on Read — which is the only thing that ends
|
||||
// a session below.
|
||||
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
conn, err := d.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
@@ -423,6 +441,8 @@ func (s *session) runOnce() (time.Time, error) {
|
||||
}
|
||||
|
||||
var connectedAt time.Time
|
||||
var quiet time.Duration // how long the node has said nothing
|
||||
var pending string // a line cut in half by a read deadline
|
||||
rd := bufio.NewReader(conn)
|
||||
for {
|
||||
select {
|
||||
@@ -431,11 +451,37 @@ func (s *session) runOnce() (time.Time, error) {
|
||||
default:
|
||||
}
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(120 * time.Second))
|
||||
line, err := rd.ReadString('\n')
|
||||
// The deadline exists to keep this loop responsive to stopCh, NOT to
|
||||
// judge the link. A quiet node is a quiet node: some clusters send
|
||||
// nothing for minutes on a dead band, and tearing the socket down over
|
||||
// it produced a reconnect every two minutes — which loses the login,
|
||||
// the filters, and any spot that arrived during the gap.
|
||||
//
|
||||
// Only a REAL error ends the session. A dead peer still gets caught:
|
||||
// TCP keepalive probes an idle connection and its failure arrives here
|
||||
// as an error, not as a timeout.
|
||||
_ = conn.SetReadDeadline(time.Now().Add(idleTick))
|
||||
chunk, err := rd.ReadString('\n')
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
// ReadString hands back what it HAD read along with the timeout.
|
||||
// Keep it: a spot line straddling the deadline would otherwise lose
|
||||
// its first half and arrive as nonsense, or vanish entirely.
|
||||
pending += chunk
|
||||
quiet += idleTick
|
||||
// Said once at the first long silence, so a genuinely mute node is
|
||||
// visible without a line every tick.
|
||||
if quiet == quietNotice {
|
||||
applog.Printf("cluster[%s] no traffic for %s — still connected", s.cfg.Name, quiet)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return connectedAt, fmt.Errorf("read: %w", err)
|
||||
}
|
||||
quiet = 0
|
||||
line := pending + chunk
|
||||
pending = ""
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
// Strip terminal BELLs (\a) and other control bytes that some nodes
|
||||
// (e.g. F5LEN) append to spot lines — "DX de …0935Z KN04\a\a" — which
|
||||
@@ -602,6 +648,18 @@ func parseShowDX(line string) (Spot, bool) {
|
||||
}
|
||||
|
||||
func parseSpot(line string) (Spot, bool) {
|
||||
// A cluster prints its prompt WITHOUT a trailing newline, so the first spot
|
||||
// to arrive gets glued onto it and the line reads
|
||||
// "login: DX de YO2CK-#: 21074.0 PY4PTO FT8 …". The anchor then fails
|
||||
// and the spot is dropped — silently, apart from an UNPARSED log line.
|
||||
//
|
||||
// Cutting everything ahead of "DX de" rather than un-anchoring the pattern:
|
||||
// an un-anchored match would also accept a talk/announce line that merely
|
||||
// QUOTES a spot, and start injecting other people's conversations as real
|
||||
// spots.
|
||||
if i := strings.Index(line, "DX de"); i > 0 {
|
||||
line = line[i:]
|
||||
}
|
||||
m := spotRE.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return Spot{}, false
|
||||
|
||||
@@ -14,6 +14,12 @@ func TestParseSpot(t *testing.T) {
|
||||
`DX de DK0SWL: 14195.5 W1AW CQ Field Day 1745Z`,
|
||||
"W1AW", 14195.5, "20m", "",
|
||||
},
|
||||
{
|
||||
// The cluster prints its prompt with no trailing newline, so the
|
||||
// first spot arrives glued to it. Seen live on 2026-08-02.
|
||||
`login: DX de YO2CK-#: 21074.0 PY4PTO FT8 -16 dB 1733Z`,
|
||||
"PY4PTO", 21074.0, "15m", "",
|
||||
},
|
||||
{
|
||||
`DX de F4XYZ-#: 7074.0 3DA0RU FT8 -10 0823Z JN18`,
|
||||
"3DA0RU", 7074.0, "40m", "JN18",
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A quiet cluster must not be treated as a broken one.
|
||||
//
|
||||
// Reported by an operator whose node sends little: nothing for two minutes and
|
||||
// OpsLog reconnected — losing the login, the filters, and any spot that landed
|
||||
// during the gap. The read deadline was being read as a verdict on the link
|
||||
// rather than as a way to stay responsive to a stop request.
|
||||
//
|
||||
// The test holds the connection open, silent for longer than one deadline, then
|
||||
// sends a spot. A session that survives receives it; one that reconnects does
|
||||
// not, because this listener never accepts twice.
|
||||
func TestSilenceDoesNotEndTheSession(t *testing.T) {
|
||||
// Shorten the tick so the test exercises the real code path in seconds.
|
||||
defer func(orig time.Duration) { idleTick = orig }(idleTick)
|
||||
idleTick = time.Second
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
accepted := make(chan net.Conn, 2)
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
accepted <- c
|
||||
}
|
||||
}()
|
||||
|
||||
host, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
spots := make(chan Spot, 4)
|
||||
s := &session{
|
||||
cfg: ServerConfig{Name: "quiet", Host: host, Port: port},
|
||||
onSpot: func(sp Spot) { spots <- sp },
|
||||
onLine: func(Line) {},
|
||||
onStatus: func() {},
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { _, err := s.runOnce(); done <- err }()
|
||||
|
||||
conn := <-accepted
|
||||
defer conn.Close()
|
||||
|
||||
// Silent for longer than one deadline: the loop must ride through it.
|
||||
time.Sleep(3 * idleTick)
|
||||
|
||||
select {
|
||||
case c := <-accepted:
|
||||
c.Close()
|
||||
t.Fatal("the session reconnected during the silence")
|
||||
case err := <-done:
|
||||
t.Fatalf("the session ended during the silence: %v", err)
|
||||
default:
|
||||
}
|
||||
|
||||
// Now a spot: it proves the ORIGINAL connection is still the live one.
|
||||
if _, err := conn.Write([]byte("DX de F4BPO: 14074.0 OY1CT FT8 1234Z\r\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case sp := <-spots:
|
||||
if sp.DXCall != "OY1CT" {
|
||||
t.Errorf("spot from the wrong station: %+v", sp)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the spot sent after the silence never arrived")
|
||||
}
|
||||
|
||||
close(s.stopCh)
|
||||
<-done
|
||||
}
|
||||
@@ -125,6 +125,10 @@ type Decoder struct {
|
||||
pendHops int // consecutive hops the raw state has disagreed
|
||||
|
||||
// Two-cluster element timing (ms).
|
||||
// muLetterGap is this operator's typical gap BETWEEN LETTERS (ms), so the
|
||||
// word boundary can follow a wide fist instead of chopping up callsigns.
|
||||
// Zero until enough gaps have been seen.
|
||||
muLetterGap float64
|
||||
muDit, muDah float64
|
||||
marksSeen int
|
||||
|
||||
@@ -195,6 +199,19 @@ const (
|
||||
|
||||
charGapDits = 2.2 // gap > this ⇒ character boundary (geom. mean of 1 & 3 ≈ 1.7, plus margin for sloppy fists)
|
||||
wordGapDits = 4.6 // gap > this ⇒ word boundary (geom. mean of 3 & 7)
|
||||
|
||||
// newOverFloorMs is the shortest silence that may end an over — see newOverMs.
|
||||
// Past it the speed estimate is dropped: the next voice on the frequency is
|
||||
// probably somebody else, and the last operator's speed is not evidence
|
||||
// about them. Two seconds is far longer than any word gap (7 dits is 0.42 s
|
||||
// even at 10 wpm) and far shorter than a pause between overs.
|
||||
newOverFloorMs = 600.0
|
||||
|
||||
// wordGapRatio places the word boundary relative to the letter gaps this
|
||||
// operator ACTUALLY sends, for fists whose letter spacing runs wide. The
|
||||
// fixed 4.6-dit rule turned a 5-dit letter gap into a word, so a callsign
|
||||
// arrived as "O Y 1 C T" — which is worse than two words run together.
|
||||
wordGapRatio = 1.6
|
||||
)
|
||||
|
||||
// New builds a decoder for the given sample rate. onChar receives decoded text
|
||||
@@ -260,6 +277,7 @@ func (d *Decoder) Reset() {
|
||||
d.stableHops, d.pendHops = 0, 0
|
||||
d.bankTick, d.betterHops = 0, 0
|
||||
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
|
||||
d.muLetterGap = 0
|
||||
d.elemMs = d.elemMs[:0]
|
||||
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
|
||||
}
|
||||
@@ -578,6 +596,30 @@ func (d *Decoder) endMark(hops int) {
|
||||
// steadier than dits in hand keying).
|
||||
func (d *Decoder) endSpace(hops int) {
|
||||
ms := float64(hops)*d.hopMs + d.biasMs // gaps shrink by what marks gained
|
||||
|
||||
// A long silence ends the over — see newOverMs. Forget the speed: coming back at 14 wpm
|
||||
// after following someone at 32, every element of the newcomer measured
|
||||
// longer than the stale dah threshold and the first words decoded as a run
|
||||
// of T's. Starting from the seed instead, the estimate re-converges within a
|
||||
// character — which is exactly how a freshly started decoder behaves, and
|
||||
// that case was always fine.
|
||||
if d.marksSeen > 0 && ms > d.newOverMs() {
|
||||
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
|
||||
d.muLetterGap = 0
|
||||
d.elemMs = d.elemMs[:0]
|
||||
return
|
||||
}
|
||||
|
||||
// Letter gaps: everything between a character boundary and a word boundary.
|
||||
// Kept so the word boundary can follow this operator's own spacing.
|
||||
if d.marksSeen > 0 && ms > charGapDits*d.muDit && ms < wordGapDits*d.muDit*2 {
|
||||
if d.muLetterGap == 0 {
|
||||
d.muLetterGap = ms
|
||||
} else {
|
||||
d.muLetterGap += (ms - d.muLetterGap) * 0.2
|
||||
}
|
||||
}
|
||||
|
||||
if d.marksSeen < 1 || ms < 0.35*d.muDit || ms > 1.7*d.muDit {
|
||||
return
|
||||
}
|
||||
@@ -592,6 +634,40 @@ func (d *Decoder) endSpace(hops int) {
|
||||
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
|
||||
}
|
||||
|
||||
// newOverMs is the silence above which the speed estimate is dropped.
|
||||
//
|
||||
// It cannot be a fixed duration: 0.75 s is a quick turnaround at 30 wpm but is
|
||||
// barely a word gap at 10 wpm (7 dits = 0.84 s). So it scales with the current
|
||||
// estimate, with a floor so a fast operator pausing to think is not mistaken
|
||||
// for a new station on every hesitation.
|
||||
func (d *Decoder) newOverMs() float64 {
|
||||
if v := 12 * d.muDit; v > newOverFloorMs {
|
||||
return v
|
||||
}
|
||||
return newOverFloorMs
|
||||
}
|
||||
|
||||
// wordGapMs is the silence above which a word boundary is declared.
|
||||
//
|
||||
// The textbook answer is 4.6 dits — the geometric mean of a 3-dit letter gap
|
||||
// and a 7-dit word gap. It assumes the operator sends textbook spacing. Many do
|
||||
// not: a fist that leaves 5 dits between letters had every letter turned into a
|
||||
// word, so callsigns arrived in pieces.
|
||||
//
|
||||
// So the boundary also follows the letter gaps actually observed. Whichever is
|
||||
// larger wins: a textbook fist keeps the textbook boundary, a wide one gets a
|
||||
// wider boundary. The cost is that a wide sender's words may run together —
|
||||
// which is a far smaller price than a callsign broken into letters.
|
||||
func (d *Decoder) wordGapMs() float64 {
|
||||
fixed := wordGapDits * d.muDit
|
||||
if d.muLetterGap > 0 {
|
||||
if adaptive := wordGapRatio * d.muLetterGap; adaptive > fixed {
|
||||
return adaptive
|
||||
}
|
||||
}
|
||||
return fixed
|
||||
}
|
||||
|
||||
// spaceProgress emits the pending character / word space LIVE once the current
|
||||
// gap crosses each boundary (instead of waiting for the next mark), so text
|
||||
// appears as it is sent.
|
||||
@@ -601,7 +677,7 @@ func (d *Decoder) spaceProgress() {
|
||||
d.flushChar()
|
||||
d.charEmitted = true
|
||||
}
|
||||
if !d.wordEmitted && gapMs > wordGapDits*d.muDit {
|
||||
if !d.wordEmitted && gapMs > d.wordGapMs() {
|
||||
if d.textSince && d.onChar != nil {
|
||||
d.onChar(" ")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package cwdecode
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// keyLoose keys a message with SLIGHTLY WIDE letter spacing — the way a great
|
||||
// many operators actually send. letterGapDits replaces the standard 3.
|
||||
//
|
||||
// Same envelope shaping as keyMessage: hard edges would make an easier signal
|
||||
// than anything on the air, and would prove nothing.
|
||||
func keyLoose(msg string, fs, wpm int, pitch, amp float64, letterGapDits float64) []int16 {
|
||||
dot := fs * 1200 / (wpm * 1000)
|
||||
edge := fs * 5 / 1000
|
||||
c2m := charToMorse()
|
||||
var out []float64
|
||||
phase := 0.0
|
||||
dphi := 2 * math.Pi * pitch / float64(fs)
|
||||
|
||||
tone := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
g := 1.0
|
||||
if i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(i)/float64(edge))
|
||||
} else if n-1-i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(n-1-i)/float64(edge))
|
||||
}
|
||||
out = append(out, amp*g*math.Sin(phase))
|
||||
phase += dphi
|
||||
}
|
||||
}
|
||||
silence := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, 0)
|
||||
phase += dphi
|
||||
}
|
||||
}
|
||||
|
||||
silence(fs / 4)
|
||||
for i := 0; i < len(msg); i++ {
|
||||
ch := msg[i]
|
||||
if ch == ' ' {
|
||||
silence(4 * dot)
|
||||
continue
|
||||
}
|
||||
code := c2m[ch]
|
||||
for j := 0; j < len(code); j++ {
|
||||
if code[j] == '.' {
|
||||
tone(dot)
|
||||
} else {
|
||||
tone(3 * dot)
|
||||
}
|
||||
silence(dot)
|
||||
}
|
||||
// The element gap above already contributes one dit.
|
||||
silence(int((letterGapDits - 1) * float64(dot)))
|
||||
}
|
||||
silence(fs / 2)
|
||||
return toInt16(out)
|
||||
}
|
||||
|
||||
// The first character of an over must decode, not arrive as "?".
|
||||
//
|
||||
// Reported on the air: "the first letter or digit often turns into ?". Each
|
||||
// element is classified against the running dit estimate, and at the start of
|
||||
// an over that estimate belongs to whatever was decoded last — a different
|
||||
// operator, at a different speed. The first character pays for it.
|
||||
func TestFirstCharacterOfAnOver(t *testing.T) {
|
||||
const fs = 16000
|
||||
for _, wpm := range []int{15, 22, 30} {
|
||||
got := decode(t, keyMessage("F4BPO DE OY1CT K", fs, wpm, 700, 9000), 0)
|
||||
if strings.HasPrefix(got, "?") {
|
||||
t.Errorf("@%d wpm: decoded %q — the first character was lost", wpm, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A callsign must not be broken up when the sender's letter spacing is a little
|
||||
// wide.
|
||||
//
|
||||
// Reported on the air: "sometimes there are spaces inside the call". A word
|
||||
// boundary is declared above 4.6 dits of silence, so an operator whose letter
|
||||
// gaps run to 4.5 dits has every letter turned into a word of its own.
|
||||
func TestWideLetterSpacingDoesNotSplitTheCall(t *testing.T) {
|
||||
const fs = 16000
|
||||
for _, gap := range []float64{3.5, 4.0, 4.5} {
|
||||
got := decode(t, keyLoose("DE OY1CT K", fs, 20, 700, 9000, gap), 0)
|
||||
for _, split := range []string{"O Y", "Y 1", "1 C", "C T"} {
|
||||
if strings.Contains(got, split) {
|
||||
t.Errorf("letter gap %.1f dits: decoded %q — the callsign was broken at %q", gap, got, split)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decodeSeq runs several transmissions through the SAME decoder, as happens on
|
||||
// the air: the estimate carried into an over is whatever the previous station
|
||||
// left behind.
|
||||
func decodeSeq(t *testing.T, parts ...[]int16) []string {
|
||||
t.Helper()
|
||||
var cur strings.Builder
|
||||
d := New(16000, func(s string) { cur.WriteString(s) }, nil)
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
cur.Reset()
|
||||
for i := 0; i < len(p); i += 256 {
|
||||
end := i + 256
|
||||
if end > len(p) {
|
||||
end = len(p)
|
||||
}
|
||||
d.Process(p[i:end])
|
||||
}
|
||||
out = append(out, strings.ToUpper(cur.String()))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A fast station, then a slow one — the real reason the first character is
|
||||
// lost. A fresh decoder starts from a neutral estimate and adapts within a
|
||||
// character or two; a decoder that has just followed someone at 30 wpm judges
|
||||
// the newcomer's first dits against 30 wpm.
|
||||
func TestFirstCharacterAfterASpeedChange(t *testing.T) {
|
||||
const fs = 16000
|
||||
got := decodeSeq(t,
|
||||
keyMessage("CQ CQ DE DL1ABC K", fs, 32, 700, 9000),
|
||||
keyMessage("DL1ABC DE OY1CT K", fs, 14, 700, 9000),
|
||||
)
|
||||
if strings.HasPrefix(got[1], "?") {
|
||||
t.Errorf("after 32→14 wpm: %q — the first character of the reply was lost", got[1])
|
||||
}
|
||||
if !strings.Contains(got[1], "OY1CT") {
|
||||
t.Errorf("after 32→14 wpm: %q — want the callsign intact", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Hand-sent letter gaps run wide. Above 4.6 dits every letter becomes a word,
|
||||
// so a callsign arrives in pieces.
|
||||
func TestVeryWideLetterSpacing(t *testing.T) {
|
||||
const fs = 16000
|
||||
for _, gap := range []float64{5.0, 5.5, 6.0} {
|
||||
got := decode(t, keyLoose("DE OY1CT K", fs, 18, 700, 9000, gap), 0)
|
||||
if strings.Contains(got, "O Y") || strings.Contains(got, "Y 1") || strings.Contains(got, "1 C") {
|
||||
t.Errorf("letter gap %.1f dits: decoded %q — the callsign was broken up", gap, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,39 @@ func (m *Manager) EntityNames() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// PrefixByDXCC maps ADIF DXCC entity number → canonical primary prefix (F, DL,
|
||||
// XE…) from the loaded cty.dat. cty.dat is keyed by entity NAME, so the join
|
||||
// goes through EntityDXCC; entries whose name doesn't resolve are skipped
|
||||
// rather than guessed. Empty until cty.dat has loaded.
|
||||
func (m *Manager) PrefixByDXCC() map[int]string {
|
||||
m.mu.RLock()
|
||||
db := m.db
|
||||
m.mu.RUnlock()
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[int]string, 400)
|
||||
for _, e := range db.Entities() {
|
||||
p := strings.TrimSpace(e.Primary)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
// cty.dat marks non-DXCC sub-entities with a leading '*' — they report
|
||||
// under a parent entity and must not overwrite the parent's prefix.
|
||||
if strings.HasPrefix(p, "*") {
|
||||
continue
|
||||
}
|
||||
if n := EntityDXCC(e.Name); n > 0 {
|
||||
if _, dup := out[n]; !dup {
|
||||
// DXCC prefixes are conventionally upper-case; cty.dat spells some
|
||||
// sub-entity suffixes in lower case (3D2/c → 3D2/C).
|
||||
out[n] = strings.ToUpper(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Info returns metadata about the currently-loaded cty.dat (or zero value
|
||||
// if nothing loaded).
|
||||
func (m *Manager) Info() ctySource {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package extsvc
|
||||
|
||||
// Withdrawing a QSO from the external services after it is deleted locally.
|
||||
//
|
||||
// The two services could hardly be less alike, and the difference decides what
|
||||
// is possible for QSOs already in the log:
|
||||
//
|
||||
// - QRZ.com deletes ONLY by logid — the identifier it returns on INSERT.
|
||||
// There is no delete-by-callsign-and-date. A QSO uploaded before OpsLog
|
||||
// started recording that identifier therefore cannot be withdrawn from
|
||||
// here; the operator has to do it on the website. QRZ's own documentation
|
||||
// puts it plainly: "There is no undo from this operation."
|
||||
// - Club Log deletes by MATCH: the worked callsign, the exact timestamp and
|
||||
// the band. Nothing has to have been stored in advance, so this works for
|
||||
// every QSO in the log, past or future.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// clublogDeleteURL is Club Log's real-time single-QSO delete endpoint.
|
||||
const clublogDeleteURL = "https://clublog.org/delete.php"
|
||||
|
||||
// DeleteQRZ removes QSOs from a QRZ logbook by their logid.
|
||||
//
|
||||
// The identifiers come from the LOGID QRZ returns on INSERT, which OpsLog
|
||||
// stores on the QSO as APP_OPSLOG_QRZ_LOGID.
|
||||
//
|
||||
// QRZ answers RESULT=OK when every id was deleted, PARTIAL when some were not
|
||||
// found, FAIL when none were. PARTIAL and FAIL are NOT treated as errors here:
|
||||
// the QSO is being deleted locally either way, and "it was already gone from
|
||||
// QRZ" is the outcome the operator wanted. The message is returned so the
|
||||
// caller can log what actually happened.
|
||||
func DeleteQRZ(ctx context.Context, client *http.Client, apiKey string, logIDs []string) (string, error) {
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if apiKey == "" {
|
||||
return "", fmt.Errorf("qrz: api key not set")
|
||||
}
|
||||
ids := make([]string, 0, len(logIDs))
|
||||
for _, id := range logIDs {
|
||||
if id = strings.TrimSpace(id); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return "", fmt.Errorf("qrz: no logid recorded for this QSO — it can only be removed on qrz.com")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("KEY", apiKey)
|
||||
form.Set("ACTION", "DELETE")
|
||||
form.Set("LOGIDS", strings.Join(ids, ","))
|
||||
|
||||
body, err := postForm(ctx, client, qrzAPIURL, form, 20*time.Second)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("qrz: %w", err)
|
||||
}
|
||||
LogSink("qrz: DELETE raw response: %s", strings.TrimSpace(body))
|
||||
|
||||
vals, _ := url.ParseQuery(strings.TrimSpace(body))
|
||||
result := strings.ToUpper(strings.TrimSpace(vals.Get("RESULT")))
|
||||
count := strings.TrimSpace(vals.Get("COUNT"))
|
||||
switch result {
|
||||
case "OK", "PARTIAL", "FAIL":
|
||||
return fmt.Sprintf("RESULT=%s COUNT=%s", result, count), nil
|
||||
case "AUTH":
|
||||
return "", fmt.Errorf("qrz: the logbook key was refused")
|
||||
}
|
||||
return "", fmt.Errorf("qrz: unexpected reply %q", strings.TrimSpace(body))
|
||||
}
|
||||
|
||||
// DeleteClublog removes one QSO from a Club Log logbook.
|
||||
//
|
||||
// whenUTC must be the QSO's start time; Club Log matches on it EXACTLY, to the
|
||||
// second, so a rounded or local-time value simply finds nothing. band is an
|
||||
// ADIF band name ("20m"), translated below to the band number Club Log wants.
|
||||
func DeleteClublog(ctx context.Context, client *http.Client, cfg ServiceConfig, dxCall string, whenUTC time.Time, band string) (string, error) {
|
||||
email := strings.TrimSpace(cfg.Email)
|
||||
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
|
||||
dx := strings.ToUpper(strings.TrimSpace(dxCall))
|
||||
switch {
|
||||
case email == "":
|
||||
return "", fmt.Errorf("clublog: account email not set")
|
||||
case cfg.Password == "":
|
||||
return "", fmt.Errorf("clublog: password not set")
|
||||
case call == "":
|
||||
return "", fmt.Errorf("clublog: logbook callsign not set")
|
||||
case dx == "":
|
||||
return "", fmt.Errorf("clublog: no callsign to delete")
|
||||
case whenUTC.IsZero():
|
||||
return "", fmt.Errorf("clublog: the QSO has no time — Club Log matches on it exactly")
|
||||
}
|
||||
bandID, ok := clublogBandID(band)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("clublog: band %q is not one Club Log accepts for deletion", band)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("email", email)
|
||||
form.Set("password", cfg.Password)
|
||||
form.Set("callsign", call)
|
||||
form.Set("dxcall", dx)
|
||||
form.Set("datetime", whenUTC.UTC().Format("2006-01-02 15:04:05"))
|
||||
form.Set("bandid", strconv.Itoa(bandID))
|
||||
api := strings.TrimSpace(cfg.APIKey)
|
||||
if api == "" {
|
||||
api = clublogAppAPIKey
|
||||
}
|
||||
form.Set("api", api)
|
||||
|
||||
body, err := postForm(ctx, client, clublogDeleteURL, form, 20*time.Second)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("clublog: %w", err)
|
||||
}
|
||||
msg := strings.TrimSpace(body)
|
||||
LogSink("clublog: DELETE raw response: %s", msg)
|
||||
// "QSO Not Deleted" means no record matched. That is not an error worth
|
||||
// stopping for — the QSO is going away locally regardless, and a QSO absent
|
||||
// from Club Log is the state being asked for.
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// clublogBandID maps an ADIF band name to Club Log's band number.
|
||||
//
|
||||
// The list is Club Log's own, and it is deliberately NOT derived from the
|
||||
// frequency: Club Log accepts these values and no others, so a band outside it
|
||||
// must be reported rather than approximated to a neighbour.
|
||||
func clublogBandID(band string) (int, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(band)) {
|
||||
case "160m":
|
||||
return 160, true
|
||||
case "80m":
|
||||
return 80, true
|
||||
case "60m":
|
||||
return 60, true
|
||||
case "40m":
|
||||
return 40, true
|
||||
case "30m":
|
||||
return 30, true
|
||||
case "20m":
|
||||
return 20, true
|
||||
case "17m":
|
||||
return 17, true
|
||||
case "15m":
|
||||
return 15, true
|
||||
case "12m":
|
||||
return 12, true
|
||||
case "10m":
|
||||
return 10, true
|
||||
case "6m":
|
||||
return 6, true
|
||||
case "4m":
|
||||
return 4, true
|
||||
case "2m":
|
||||
return 2, true
|
||||
case "70cm":
|
||||
return 70, true
|
||||
case "23cm":
|
||||
return 23, true
|
||||
case "13cm":
|
||||
return 13, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// postForm is the shared request/read for both deletes.
|
||||
func postForm(ctx context.Context, client *http.Client, endpoint string, form url.Values, timeout time.Duration) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", clublogUserAgent)
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: timeout}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
b := string(raw)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(b))
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package extsvc
|
||||
|
||||
import "testing"
|
||||
|
||||
// Club Log matches a deletion on callsign, exact time and BAND NUMBER. A band
|
||||
// that maps to the wrong number does not fail — it points the delete at a
|
||||
// different QSO, or at nothing, silently. So the table is pinned.
|
||||
func TestClublogBandID(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
band string
|
||||
want int
|
||||
}{
|
||||
{"160m", 160}, {"80m", 80}, {"60m", 60}, {"40m", 40}, {"30m", 30},
|
||||
{"20m", 20}, {"17m", 17}, {"15m", 15}, {"12m", 12}, {"10m", 10},
|
||||
{"6m", 6}, {"4m", 4}, {"2m", 2},
|
||||
{"70cm", 70}, {"23cm", 23}, {"13cm", 13},
|
||||
{"20M", 20}, {" 20m ", 20}, // ADIF case and stray spaces
|
||||
} {
|
||||
got, ok := clublogBandID(c.band)
|
||||
if !ok || got != c.want {
|
||||
t.Errorf("clublogBandID(%q) = %d,%v — want %d", c.band, got, ok, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Bands Club Log does not accept must be REFUSED, never approximated to a
|
||||
// neighbour: 6 cm silently becoming 13 cm would delete somebody else's QSO.
|
||||
for _, bad := range []string{"", "6cm", "3cm", "2200m", "630m", "9cm", "banana"} {
|
||||
if id, ok := clublogBandID(bad); ok {
|
||||
t.Errorf("clublogBandID(%q) accepted as %d — it is not a Club Log band", bad, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,13 @@ type ExternalServices struct {
|
||||
HRDLog ServiceConfig `json:"hrdlog"`
|
||||
EQSL ServiceConfig `json:"eqsl"`
|
||||
Cloudlog ServiceConfig `json:"cloudlog"`
|
||||
|
||||
// DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when
|
||||
// it is deleted locally. Off unless the operator turns it on: neither
|
||||
// service can undo it, and a local delete is not always meant to reach the
|
||||
// world — a duplicate cleared from a contest log was never meant to be a
|
||||
// statement about the DX's log.
|
||||
DeleteRemote bool `json:"delete_remote"`
|
||||
}
|
||||
|
||||
// UploadResult is the outcome of a single upload attempt.
|
||||
|
||||
@@ -86,7 +86,13 @@ type Server struct {
|
||||
stopped bool
|
||||
mu sync.Mutex
|
||||
|
||||
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
||||
// WSJT: dial frequency from the last Status, added to Decode audio offsets —
|
||||
// keyed by the WSJT-X instance id, NOT one value per listener. Two instances
|
||||
// share one multicast group, so a single value was overwritten by whichever
|
||||
// sent Status last: decodes from the 50.313 receiver were placed on the
|
||||
// 50.400 panadapter. WSJT-X requires --rig-name for a second instance, so the
|
||||
// id is distinct whenever there is more than one.
|
||||
dialHz map[string]int64
|
||||
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
|
||||
}
|
||||
|
||||
@@ -196,7 +202,6 @@ func (s *Server) run() {
|
||||
}
|
||||
|
||||
func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
applog.Printf("udp: [%s] rx %d bytes from %s\n", s.cfg.Name, len(pkt), remote)
|
||||
ev := Event{ConfigID: s.cfg.ID, Service: s.cfg.ServiceType, Source: remote.String()}
|
||||
switch s.cfg.ServiceType {
|
||||
case ServiceWSJT:
|
||||
@@ -212,15 +217,21 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
// offsets can be turned into RF frequencies for the panadapter.
|
||||
if w.FreqHz > 0 && !w.IsDecode {
|
||||
s.mu.Lock()
|
||||
s.lastDialHz = w.FreqHz
|
||||
if s.dialHz == nil {
|
||||
s.dialHz = map[string]int64{}
|
||||
}
|
||||
s.dialHz[w.ProgramID] = w.FreqHz
|
||||
s.mu.Unlock()
|
||||
}
|
||||
if w.IsDecode {
|
||||
s.mu.Lock()
|
||||
dial := s.lastDialHz
|
||||
dial := s.dialHz[w.ProgramID]
|
||||
s.mu.Unlock()
|
||||
if dial <= 0 {
|
||||
return // no dial freq yet (Status not seen) → can't place the spot
|
||||
// No Status from THIS instance yet. Guessing with another
|
||||
// instance's dial is what produced the wrong-panadapter spots,
|
||||
// so drop the decode and wait — Status arrives every second.
|
||||
return
|
||||
}
|
||||
ev.DecodeCall = w.DecodeCall
|
||||
ev.DecodeFreqHz = dial + w.DeltaFreqHz
|
||||
@@ -229,8 +240,12 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
ev.Mode = w.Mode
|
||||
break
|
||||
}
|
||||
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
||||
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
||||
// Only a logged QSO is worth a line — WSJT-X/MSHV send a Status packet
|
||||
// every second, and logging each one buried the rest of the file.
|
||||
if len(w.LoggedADIF) > 0 {
|
||||
applog.Printf("udp: [%s] WSJT QSO logged: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
||||
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
||||
}
|
||||
ev.DXCall = w.DXCall
|
||||
ev.DXGrid = w.DXGrid
|
||||
ev.Mode = w.Mode
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# QRZ.com XML API — what a free account returns, and what a subscription adds
|
||||
|
||||
Captured 2026-08-02 on F4BPO's two accounts, same callsign looked up twice
|
||||
within a minute. Kept because the difference is the answer to a question that
|
||||
comes back regularly: *"someone told me their logger gets the 6-character
|
||||
locator from QRZ without paying"*. It does not.
|
||||
|
||||
## Free account — `SubExp: non-subscriber`
|
||||
|
||||
```xml
|
||||
<Callsign>
|
||||
<call>F4BPO</call>
|
||||
<fname>Gregory</fname>
|
||||
<name>Salaun</name>
|
||||
<addr2>Veigy-Foncenex</addr2>
|
||||
<country>France</country>
|
||||
</Callsign>
|
||||
<Session>
|
||||
<SubExp>non-subscriber</SubExp>
|
||||
<Message>A subscription is required to access the complete record.</Message>
|
||||
</Session>
|
||||
```
|
||||
|
||||
**Five fields.** No `grid`, no `lat`/`lon`, no `dxcc`, no zones, no email, no
|
||||
street, no image.
|
||||
|
||||
## Subscriber — `SubExp: Sat Jul 3 21:45:38 2027`
|
||||
|
||||
```xml
|
||||
<Callsign>
|
||||
<call>F4BPO</call> <dxcc>227</dxcc> <nickname>Greg</nickname>
|
||||
<fname>Gregory</fname> <name>Salaun</name>
|
||||
<addr2>Veigy-Foncenex</addr2><zip>74140</zip> <country>France</country>
|
||||
<lat>46.103333</lat> <lon>6.290000</lon> <grid>JN36dc</grid>
|
||||
<ccode>97</ccode> <land>France</land> <class>2</class>
|
||||
<codes>E</codes> <qslmgr>Lotw, Buro, Direct</qslmgr>
|
||||
<email>…</email> <u_views>56966</u_views>
|
||||
<bio>4414</bio> <biodate>2026-07-08 15:30:58</biodate>
|
||||
<image>https://cdn-xml.qrz.com/o/f4bpo/F4BPO_Greg_png.jpg</image>
|
||||
<imageinfo>800:1200:221041</imageinfo>
|
||||
<moddate>2025-08-24 19:55:31</moddate>
|
||||
<eqsl>0</eqsl> <mqsl>1</mqsl> <lotw>1</lotw>
|
||||
<cqzone>14</cqzone> <ituzone>27</ituzone>
|
||||
<geoloc>user</geoloc> <name_fmt>Gregory "Greg" Salaun</name_fmt>
|
||||
<serial>1000134</serial>
|
||||
</Callsign>
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
- **A free QRZ account can never fill the locator, the coordinates, the zones
|
||||
or the e-mail.** If someone reports a logger doing it on a free account, they
|
||||
are either using HamQTH (free, returns a 6-character `grid`) or they hold an
|
||||
XML subscription without realising it — the QRZ.com account and the "XML
|
||||
Logbook Data" subscription are sold separately. `SubExp` in the session block
|
||||
settles it in one request.
|
||||
- **Profile edits take a few minutes to reach the XML feed.** Changing the grid
|
||||
on the website and looking the callsign up immediately returns the OLD value,
|
||||
which looks exactly like a stale cache. Re-test after a few minutes before
|
||||
suspecting the logger. (This cost an hour on 2026-08-02.)
|
||||
|
||||
## Fields OpsLog does not use yet
|
||||
|
||||
`qrz.go` maps call, name, addr1/2, zip, state, county, country, grid, lat, lon,
|
||||
dxcc, cqzone, ituzone, cont, email, qslmgr and image. Left on the table:
|
||||
|
||||
- **`geoloc`** — `user` when the operator entered the position by hand, versus a
|
||||
geocoded value derived from the postal address. This is a direct statement of
|
||||
how much the coordinates and the locator can be trusted, and it is exactly
|
||||
the question the grid-precision rule in App.tsx tries to answer by comparing
|
||||
string lengths. Worth wiring if the locator ever misbehaves again.
|
||||
- **`eqsl` / `mqsl` / `lotw`** — the station's QSL preferences as flags, more
|
||||
reliable than parsing the free-text `qslmgr`.
|
||||
- `nickname` / `name_fmt`, `class`, `moddate`, `bio`.
|
||||
@@ -29,6 +29,13 @@ type Info struct {
|
||||
IsUser bool `json:"is_user"`
|
||||
LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD
|
||||
DaysAgo int `json:"days_ago"` // days since last upload (-1 if unknown)
|
||||
// FeedDate is the newest upload date anywhere in ARRL's file, i.e. when ARRL
|
||||
// last regenerated it, and FeedStale says that date is more than two days old.
|
||||
// Without them "last uploaded 7 days ago" is unreadable: it may mean the
|
||||
// station went quiet, or that the FEED has been frozen for a week — which is
|
||||
// exactly what ARRL served on 2026-08-02, its newest line dated 2026-07-27.
|
||||
FeedDate string `json:"feed_date,omitempty"`
|
||||
FeedStale bool `json:"feed_stale"`
|
||||
}
|
||||
|
||||
// Manager holds the parsed list + cache location.
|
||||
@@ -36,6 +43,7 @@ type Manager struct {
|
||||
mu sync.RWMutex
|
||||
users map[string]time.Time // UPPER(callsign) → last-upload date (UTC)
|
||||
updated time.Time // when the cache was last refreshed
|
||||
newest time.Time // newest upload date in the file = when ARRL built it
|
||||
dir string
|
||||
client *http.Client
|
||||
}
|
||||
@@ -100,6 +108,7 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
|
||||
// parse loads the CSV bytes into the map and returns the count.
|
||||
func (m *Manager) parse(data []byte) int {
|
||||
users := make(map[string]time.Time, 1<<20)
|
||||
var newest time.Time
|
||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
@@ -120,16 +129,29 @@ func (m *Manager) parse(data []byte) int {
|
||||
continue
|
||||
}
|
||||
users[call] = t
|
||||
if t.After(newest) {
|
||||
newest = t
|
||||
}
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return 0
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.users = users
|
||||
m.newest = newest
|
||||
m.mu.Unlock()
|
||||
return len(users)
|
||||
}
|
||||
|
||||
// feedInfo fills the feed-age fields shared by every Lookup result.
|
||||
func (m *Manager) feedInfo(i Info) Info {
|
||||
if !m.newest.IsZero() {
|
||||
i.FeedDate = m.newest.Format("2006-01-02")
|
||||
i.FeedStale = time.Since(m.newest) > 48*time.Hour
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// Lookup reports whether callsign is a LoTW user and how long ago it uploaded.
|
||||
// Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P")
|
||||
// the longest slash-separated segment — the base call LoTW is keyed on.
|
||||
@@ -154,13 +176,13 @@ func (m *Manager) Lookup(callsign string) Info {
|
||||
t, ok = m.users[base]
|
||||
}
|
||||
if !ok {
|
||||
return Info{DaysAgo: -1}
|
||||
return m.feedInfo(Info{DaysAgo: -1})
|
||||
}
|
||||
days := int(time.Since(t).Hours() / 24)
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
return Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days}
|
||||
return m.feedInfo(Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days})
|
||||
}
|
||||
|
||||
// Count returns how many callsigns are loaded.
|
||||
|
||||
@@ -9,6 +9,7 @@ package powergenius
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -23,7 +24,13 @@ const (
|
||||
defaultPort = 9008
|
||||
dialTimeout = 5 * time.Second
|
||||
ioTimeout = 3 * time.Second
|
||||
pollEvery = 1500 * time.Millisecond
|
||||
// Poll fast enough that the amp's OWN forward/current figures make a usable
|
||||
// live meter on their own — the UI prefers them over the FlexRadio VITA stream
|
||||
// (which never traverses a public-IP/NAT link), so this direct reading is what
|
||||
// an operator watches when running the amp over the internet. At 250 ms the
|
||||
// SSB envelope is sampled often enough that peak-hold keeps a steady reading
|
||||
// instead of collapsing to ~1 W in the gaps between syllables.
|
||||
pollEvery = 250 * time.Millisecond
|
||||
reconnectDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
@@ -36,11 +43,22 @@ type Status struct {
|
||||
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
|
||||
Temperature float64 `json:"temperature"`
|
||||
Operate bool `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it)
|
||||
|
||||
// Live power, read straight from the amplifier's own status frame rather
|
||||
// than sampled off the FlexRadio meter stream. PeakW is the amp's own peak
|
||||
// detector: a poll catches one instant of the envelope, so the plain forward
|
||||
// figure lands between syllables as often as on a peak.
|
||||
FwdW float64 `json:"fwd_w"` // forward power [W] (the frame reports dBm)
|
||||
PeakW float64 `json:"peak_w"` // peak forward power [W]
|
||||
Vswr float64 `json:"vswr"` // VSWR, derived from the frame's return loss in dB
|
||||
Id float64 `json:"id"` // drain current [A]
|
||||
PeakId float64 `json:"peak_id"` // peak drain current [A]
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
host string
|
||||
port int
|
||||
password string // remote-access code; sent as "auth <code>" when the banner announces AUTH
|
||||
|
||||
mu sync.Mutex // serialises command send/recv on the connection
|
||||
conn net.Conn
|
||||
@@ -59,11 +77,11 @@ type Client struct {
|
||||
running bool
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
func New(host string, port int, password string) *Client {
|
||||
if port <= 0 || port > 65535 {
|
||||
port = defaultPort
|
||||
}
|
||||
return &Client{host: host, port: port, stop: make(chan struct{}), status: Status{Host: host}}
|
||||
return &Client{host: host, port: port, password: strings.TrimSpace(password), stop: make(chan struct{}), status: Status{Host: host}}
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
@@ -107,9 +125,20 @@ func (c *Client) SetFanMode(mode string) error {
|
||||
default:
|
||||
return fmt.Errorf("powergenius: invalid fan mode %q", mode)
|
||||
}
|
||||
if _, err := c.command("setup fanmode=" + m); err != nil {
|
||||
// The verb the amp wants is "setup fanmode=VALUE" — confirmed LIVE: with the
|
||||
// "setup " prefix the amp replies code 0 (accepted), while the bare
|
||||
// "fanmode=VALUE" we switched to earlier is rejected (reply code 0x50000015).
|
||||
// That regression is what stopped the fan from changing; restoring "setup "
|
||||
// fixes it.
|
||||
reply, err := c.command("setup fanmode=" + m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code := replyCode(reply); code != "" && code != "0" {
|
||||
applog.Printf("pgxl: set fanmode=%s REJECTED, reply=%q", m, reply)
|
||||
return fmt.Errorf("powergenius: amp rejected fanmode=%s (code %s)", m, code)
|
||||
}
|
||||
applog.Printf("pgxl: set fanmode=%s reply=%q", m, reply)
|
||||
c.statusMu.Lock()
|
||||
c.status.FanMode = m // optimistic
|
||||
c.fanPending, c.fanPendingAt = m, time.Now()
|
||||
@@ -117,6 +146,19 @@ func (c *Client) SetFanMode(mode string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// replyCode returns the result code from an "R<id>|<code>|…" reply — "0" means
|
||||
// the amp accepted the command. Returns "" when the line isn't an R reply.
|
||||
func replyCode(reply string) string {
|
||||
if !strings.HasPrefix(reply, "R") {
|
||||
return ""
|
||||
}
|
||||
p := strings.SplitN(reply, "|", 3)
|
||||
if len(p) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(p[1])
|
||||
}
|
||||
|
||||
// SetOperate puts the amp in OPERATE (1) or STANDBY (0).
|
||||
func (c *Client) SetOperate(on bool) error {
|
||||
v := "0"
|
||||
@@ -166,12 +208,62 @@ func (c *Client) ensureConnected() error {
|
||||
}
|
||||
c.conn = conn
|
||||
c.reader = bufio.NewReader(conn)
|
||||
// Discard the version banner the device sends on connect.
|
||||
// Banner: "V…" (LAN) or "V… AUTH" (remote → authentication required, exactly
|
||||
// like the Tuner Genius / Antenna Genius). Send the remote code when the amp
|
||||
// demands it, otherwise every command comes back "Unauthorized".
|
||||
_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||
_, _ = c.reader.ReadString('\n')
|
||||
banner, _ := c.reader.ReadString('\n')
|
||||
banner = strings.TrimSpace(banner)
|
||||
applog.Printf("pgxl: connected %s → %s, banner=%q", conn.LocalAddr(), conn.RemoteAddr(), banner)
|
||||
if strings.Contains(banner, "AUTH") {
|
||||
if c.password == "" {
|
||||
applog.Printf("pgxl: device requires AUTH but no remote code set (Settings → Amplifier)")
|
||||
} else if err := c.authLocked(); err != nil {
|
||||
c.conn.Close()
|
||||
c.conn, c.reader = nil, nil
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// authLocked authenticates the remote link. Must be called with c.mu held
|
||||
// (during ensureConnected). 4O3A boxes want "auth code=<pw>" and reply
|
||||
// "R<seq>|<hex>|" with an EMPTY message — hex "0" means accepted, so the response
|
||||
// CODE decides, not the text. The device also rejects the FIRST attempt (R|FF)
|
||||
// and accepts a retry, so resend a few times before giving up.
|
||||
func (c *Client) authLocked() error {
|
||||
var lastHex string
|
||||
for try := 1; try <= 4; try++ {
|
||||
id := c.cmdID.Add(1)
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||
if _, err := fmt.Fprintf(c.conn, "C%d|auth code=%s\n", id, c.password); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||
line, err := c.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
applog.Printf("pgxl: auth reply=%q (try %d)", line, try)
|
||||
hex, msg := "", ""
|
||||
if p := strings.SplitN(line, "|", 3); len(p) >= 2 {
|
||||
hex = strings.TrimSpace(p[1])
|
||||
if len(p) == 3 {
|
||||
msg = strings.TrimSpace(p[2])
|
||||
}
|
||||
}
|
||||
// hex "0" is the standard accept; also treat an explicit OK message as success.
|
||||
if hex == "0" || (msg != "" && strings.Contains(strings.ToLower(msg), "ok")) {
|
||||
applog.Printf("pgxl: authenticated")
|
||||
return nil
|
||||
}
|
||||
lastHex = hex
|
||||
}
|
||||
return fmt.Errorf("powergenius: authentication failed after 4 tries (R|%s|) — check the remote code", lastHex)
|
||||
}
|
||||
|
||||
func (c *Client) dropConn() {
|
||||
c.mu.Lock()
|
||||
if c.conn != nil {
|
||||
@@ -226,9 +318,10 @@ func (c *Client) parse(resp string) {
|
||||
c.statusMu.Lock()
|
||||
c.status.Connected = true
|
||||
c.status.LastError = ""
|
||||
// Log each DISTINCT status payload once: the PGXL's field set isn't fully
|
||||
// documented, so this is how we learn the real key for e.g. operate/standby.
|
||||
if data != c.lastRaw {
|
||||
// Log the first REAL status frame (one that carries "key=value" fields) so the
|
||||
// field set is visible — even if an earlier reply was junk like "Unauthorized"
|
||||
// (which would otherwise latch lastRaw and hide the real frame).
|
||||
if strings.Contains(data, "=") && !strings.Contains(c.lastRaw, "=") {
|
||||
c.lastRaw = data
|
||||
applog.Printf("pgxl: status raw=%q", data)
|
||||
}
|
||||
@@ -252,7 +345,50 @@ func (c *Client) parse(resp string) {
|
||||
c.status.FanMode = dev
|
||||
case "temp":
|
||||
c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64)
|
||||
case "fwd":
|
||||
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
|
||||
c.status.FwdW = dbmToWatts(v)
|
||||
}
|
||||
case "peakfwd":
|
||||
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
|
||||
c.status.PeakW = dbmToWatts(v)
|
||||
}
|
||||
case "swr":
|
||||
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
|
||||
c.status.Vswr = returnLossToVswr(v)
|
||||
}
|
||||
case "id":
|
||||
c.status.Id, _ = strconv.ParseFloat(kv[1], 64)
|
||||
case "peakid":
|
||||
c.status.PeakId, _ = strconv.ParseFloat(kv[1], 64)
|
||||
}
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
|
||||
// dbmToWatts converts a power reading in dBm to watts (0 dBm = 1 mW). The amp
|
||||
// reports power that way — "fwd=60.5" is 1122 W, not 60 W.
|
||||
func dbmToWatts(dbm float64) float64 {
|
||||
if dbm <= 0 {
|
||||
return 0
|
||||
}
|
||||
return math.Pow(10, (dbm-30)/10)
|
||||
}
|
||||
|
||||
// returnLossToVswr converts the amp's "swr" field — a return loss in dB, sent
|
||||
// negative for a good match — into the VSWR ratio an operator reads.
|
||||
func returnLossToVswr(swrDb float64) float64 {
|
||||
rl := math.Abs(swrDb)
|
||||
if rl <= 0 {
|
||||
return 0
|
||||
}
|
||||
rho := math.Pow(10, -rl/20)
|
||||
if rho >= 1 {
|
||||
return 0
|
||||
}
|
||||
vswr := (1 + rho) / (1 - rho)
|
||||
if vswr > 99.9 || math.IsInf(vswr, 0) || math.IsNaN(vswr) {
|
||||
return 0
|
||||
}
|
||||
return vswr
|
||||
}
|
||||
|
||||
@@ -1461,6 +1461,51 @@ func (r *Repo) IterateByIDs(ctx context.Context, ids []int64, fn func(QSO) error
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// BandSlotQSOs returns every contact on one band that belongs to a slot of the
|
||||
// entry matrix: the exact callsign, or any callsign in the same DXCC entity.
|
||||
// Mode is NOT filtered here — the class (phone / CW / digital) is a derived
|
||||
// notion the caller owns, so it filters the (small) band-scoped result itself.
|
||||
//
|
||||
// The DXCC arm matches the stored dxcc column only. A QSO logged before the
|
||||
// entity was resolved has a NULL there and is genuinely unattributed; guessing
|
||||
// it back from the callsign here would disagree with the matrix above, which is
|
||||
// built from the same column.
|
||||
func (r *Repo) BandSlotQSOs(ctx context.Context, callsign string, dxccNum int, band string) ([]QSO, error) {
|
||||
call := upperTrim(callsign)
|
||||
b := strings.TrimSpace(band)
|
||||
if b == "" || (call == "" && dxccNum <= 0) {
|
||||
return []QSO{}, nil
|
||||
}
|
||||
where := "band = ?"
|
||||
args := []any{b}
|
||||
switch {
|
||||
case call != "" && dxccNum > 0:
|
||||
where += " AND (callsign = ? OR dxcc = ?)"
|
||||
args = append(args, call, dxccNum)
|
||||
case call != "":
|
||||
where += " AND callsign = ?"
|
||||
args = append(args, call)
|
||||
default:
|
||||
where += " AND dxcc = ?"
|
||||
args = append(args, dxccNum)
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT `+selectCols+` FROM qso WHERE `+where+` ORDER BY qso_date DESC, id DESC LIMIT 500`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query band slot: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []QSO{}
|
||||
for rows.Next() {
|
||||
q, err := scanQSO(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// WorkedBefore summarises prior contacts at two granularities:
|
||||
// - by exact callsign → shown as "this call worked N×"
|
||||
// - by DXCC entity → drives NEW ONE / NEW BAND / NEW MODE / NEW SLOT
|
||||
@@ -2061,6 +2106,41 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountyWorked reports whether one US county has already been worked.
|
||||
//
|
||||
// It exists so the entry panel can flag a new county without loading the whole
|
||||
// worked-county set on every keystroke — the county arrives from a QRZ lookup,
|
||||
// which is exactly when the operator is deciding whether the contact is worth
|
||||
// chasing. The comparison goes through keyFn on both sides (rather than a SQL
|
||||
// equality) because logged county spellings vary — "Los Angeles" against
|
||||
// "LOS ANGELES, CA" — and the key function is what already resolves that
|
||||
// everywhere else.
|
||||
func (r *Repo) CountyWorked(ctx context.Context, state, cnty string, keyFn func(state, cnty string) string) (bool, error) {
|
||||
want := keyFn(state, cnty)
|
||||
if want == "" {
|
||||
return false, nil
|
||||
}
|
||||
// Narrowed to the one state: a few dozen rows, not the whole logbook.
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT COALESCE(state,''), COALESCE(cnty,'') FROM qso
|
||||
WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''
|
||||
AND upper(COALESCE(state,'')) = upper(?)`, strings.TrimSpace(state))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var st, cn string
|
||||
if err := rows.Scan(&st, &cn); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if keyFn(st, cn) == want {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
// WorkedCallBandModeKeys returns the set of every worked "CALL|BAND|MODE" key
|
||||
// (all upper-cased), loaded in one pass. It backs the in-memory worked-index the
|
||||
// alert engine checks per cluster spot — a DB query per spot cannot keep up with
|
||||
@@ -2083,6 +2163,36 @@ func (r *Repo) WorkedCallBandModeKeys(ctx context.Context) (map[string]struct{},
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// WorkedCallSlotKeys backs the cluster "already worked only on the same slot"
|
||||
// option. It returns each worked slot keyed BOTH as "CALL|band" (mode-agnostic,
|
||||
// for a spot whose mode couldn't be inferred) and "CALL|band|MODE" with the mode
|
||||
// run through normMode — so when digital grouping is on, FT8/FT4/RTTY fold into
|
||||
// one "DIG" and a call worked on 20m FT8 also matches a 20m FT4 spot. Call is
|
||||
// upper-cased and band lower-cased to match the spot keys ClusterSpotStatuses
|
||||
// builds. normMode may be nil (no grouping).
|
||||
func (r *Repo) WorkedCallSlotKeys(ctx context.Context, normMode func(string) string) (map[string]struct{}, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT upper(callsign), lower(band), upper(mode) FROM qso
|
||||
WHERE callsign != '' AND band IS NOT NULL AND band != '' AND mode IS NOT NULL AND mode != ''`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]struct{}, 4096)
|
||||
for rows.Next() {
|
||||
var c, b, m string
|
||||
if err := rows.Scan(&c, &b, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if normMode != nil {
|
||||
m = normMode(m)
|
||||
}
|
||||
out[c+"|"+b] = struct{}{}
|
||||
out[c+"|"+b+"|"+m] = struct{}{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// WorkedPOTARefs returns the set of POTA park references already worked
|
||||
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks
|
||||
// (an n-fer); each is added separately.
|
||||
@@ -2506,6 +2616,23 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearQRZConfirmed takes back a QRZ confirmation.
|
||||
//
|
||||
// Needed because OpsLog set some wrongly: it read qrzcom_qso_download_status,
|
||||
// which QRZ puts on everything it hands back, as QRZ's confirmation. Those
|
||||
// QSOs count towards award slots, so leaving them green until the operator
|
||||
// notices is not an option — and nothing else in the app would ever undo them.
|
||||
func (r *Repo) ClearQRZConfirmed(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET qrzcom_qso_download_status = 'N', qrzcom_qso_download_date = NULL,
|
||||
updated_at = ? WHERE id = ?`,
|
||||
db.NowISO(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear qrz confirmed %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkEQSLConfirmed stamps EQSL_QSL_RCVD=Y and the received date on a QSO after
|
||||
// an eQSL Inbox download. date is an ADIF YYYYMMDD string.
|
||||
func (r *Repo) MarkEQSLConfirmed(ctx context.Context, id int64, date string) error {
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
// - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss}
|
||||
// (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with
|
||||
// <relay1>..<relay8> (relay0 is reserved). Optional HTTP basic auth.
|
||||
// - Dingtian IOT relay (DTWONDER) — 2/4/8/16/24/32 relays over its HTTP GET
|
||||
// CGI. Control: GET /relay_cgi.cgi?type=0&relay=N&on=1&time=0&pwd=P&;
|
||||
// status: GET /relay_cgi_load.cgi. Both answer &-separated fields.
|
||||
// (IOT Relay Programming Manual V1.9.8.1, §3.)
|
||||
//
|
||||
// A Device presents the same surface to the app regardless of wire protocol.
|
||||
package relaydev
|
||||
@@ -57,6 +61,22 @@ func get(ctx context.Context, url, user, pass string) ([]byte, error) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// relayBase turns a configured host into the base URL of a network relay board.
|
||||
// A bare host or host:port keeps the default http:// (the boards serve plain
|
||||
// HTTP on their LAN). A host that already carries a scheme is used verbatim, so
|
||||
// a board can be reached through an HTTPS reverse proxy — e.g. a Nginx Proxy
|
||||
// Manager entry "https://relay.example.com" fronting the LAN board's port 80,
|
||||
// which is how an operator reaches shack relays from outside when 80/443 are
|
||||
// already taken by the proxy. A trailing slash is trimmed so path parts append
|
||||
// cleanly; an optional sub-path in the host is preserved (a sub-path proxy).
|
||||
func relayBase(host string) string {
|
||||
h := strings.TrimSpace(host)
|
||||
if strings.HasPrefix(h, "http://") || strings.HasPrefix(h, "https://") {
|
||||
return strings.TrimRight(h, "/")
|
||||
}
|
||||
return "http://" + h
|
||||
}
|
||||
|
||||
// ── WebSwitch 1216H ────────────────────────────────────────────────────
|
||||
|
||||
type webswitch struct {
|
||||
@@ -78,7 +98,7 @@ func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
|
||||
if on {
|
||||
action = "on"
|
||||
}
|
||||
_, err := get(ctx, fmt.Sprintf("http://%s/relaycontrol/%s/%d", w.host, action, relay), "", "")
|
||||
_, err := get(ctx, fmt.Sprintf("%s/relaycontrol/%s/%d", relayBase(w.host), action, relay), "", "")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -89,7 +109,7 @@ func (w *webswitch) Status(ctx context.Context) ([]bool, error) {
|
||||
sel.WriteString(strconv.Itoa(i))
|
||||
sel.WriteByte('$')
|
||||
}
|
||||
body, err := get(ctx, fmt.Sprintf("http://%s/relaystate/get2/%s", w.host, sel.String()), "", "")
|
||||
body, err := get(ctx, fmt.Sprintf("%s/relaystate/get2/%s", relayBase(w.host), sel.String()), "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -136,7 +156,7 @@ func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
|
||||
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)
|
||||
_, err := get(ctx, fmt.Sprintf("%s/FF%02d%s", relayBase(k.host), relay, state), k.user, k.pass)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -150,7 +170,7 @@ type kmStatus struct {
|
||||
}
|
||||
|
||||
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)
|
||||
body, err := get(ctx, fmt.Sprintf("%s/status.xml", relayBase(k.host)), k.user, k.pass)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -173,3 +193,119 @@ func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── Dingtian IOT relay (DTWONDER) ──────────────────────────────────────
|
||||
//
|
||||
// The board speaks several protocols (Modbus, MQTT, CoAP, its own binary); we
|
||||
// use the HTTP GET CGI, which needs no connection state and matches how the
|
||||
// other network boards here are driven.
|
||||
//
|
||||
// Both endpoints answer &-separated fields, first one 0 on success:
|
||||
//
|
||||
// /relay_cgi_load.cgi → &0&4&1&0&1&0&
|
||||
// result, count, r1…rN
|
||||
// /relay_cgi.cgi?type=0&relay=0&on=1&time=0&pwd=0& → &0&0&0&1&0&
|
||||
// result, type, relay, on, time
|
||||
//
|
||||
// NOTE the relay index in the URL is ZERO-based (relay=0 is relay 1), while the
|
||||
// Device interface is 1-based like every other board here.
|
||||
type dingtian struct {
|
||||
host string
|
||||
session string // optional: the board can require "Cookie: session=<id>"
|
||||
pwd string // CGI password, 0–9999; "0" (or blank) when none is set
|
||||
count int
|
||||
}
|
||||
|
||||
// NewDingtian builds a Dingtian IOT relay client. session is the HTTP session ID
|
||||
// when the board has "HTTP Session" enabled (blank otherwise); pwd is its relay
|
||||
// password (blank or "0" when none).
|
||||
func NewDingtian(host, session, pwd string, count int) Device {
|
||||
if count <= 0 {
|
||||
count = 2
|
||||
}
|
||||
if strings.TrimSpace(pwd) == "" {
|
||||
pwd = "0"
|
||||
}
|
||||
return &dingtian{host: host, session: strings.TrimSpace(session), pwd: strings.TrimSpace(pwd), count: count}
|
||||
}
|
||||
|
||||
func (d *dingtian) Count() int { return d.count }
|
||||
func (d *dingtian) Close() error { return nil } // stateless HTTP, nothing to release
|
||||
|
||||
// getCGI issues the GET with the session cookie the board may require.
|
||||
func (d *dingtian) getCGI(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.session != "" {
|
||||
req.Header.Set("Cookie", "session="+d.session)
|
||||
}
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// dtFields splits an &-separated CGI reply into its fields, dropping the empty
|
||||
// ones the leading and trailing "&" produce.
|
||||
func dtFields(body []byte) []string {
|
||||
var out []string
|
||||
for _, f := range strings.Split(strings.TrimSpace(string(body)), "&") {
|
||||
if f = strings.TrimSpace(f); f != "" {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (d *dingtian) Set(ctx context.Context, relay int, on bool) error {
|
||||
if relay < 1 || relay > d.count {
|
||||
return fmt.Errorf("relay %d out of range 1..%d", relay, d.count)
|
||||
}
|
||||
state := 0
|
||||
if on {
|
||||
state = 1
|
||||
}
|
||||
// type=0 is plain ON/OFF (1 = jogging, 2 = delay, 3 = flash, 4 = toggle),
|
||||
// and time is then unused.
|
||||
body, err := d.getCGI(ctx, fmt.Sprintf("%s/relay_cgi.cgi?type=0&relay=%d&on=%d&time=0&pwd=%s&",
|
||||
relayBase(d.host), relay-1, state, d.pwd))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A wrong password or session answers 200 with a non-zero result (e.g.
|
||||
// "&302&/&"), so the HTTP status alone does not tell us it worked.
|
||||
if f := dtFields(body); len(f) == 0 || f[0] != "0" {
|
||||
return fmt.Errorf("dingtian: refused (%s) — check the relay password and the HTTP session ID",
|
||||
strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dingtian) Status(ctx context.Context) ([]bool, error) {
|
||||
body, err := d.getCGI(ctx, fmt.Sprintf("%s/relay_cgi_load.cgi", relayBase(d.host)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := dtFields(body)
|
||||
if len(f) < 2 || f[0] != "0" {
|
||||
return nil, fmt.Errorf("dingtian: bad status reply %q", strings.TrimSpace(string(body)))
|
||||
}
|
||||
// The board reports its own relay count; trust it over the configured one so
|
||||
// a mis-set channel count doesn't silently hide relays.
|
||||
if n, e := strconv.Atoi(f[1]); e == nil && n > 0 && n <= 32 {
|
||||
d.count = n
|
||||
}
|
||||
out := make([]bool, d.count)
|
||||
for i := 0; i < d.count && i+2 < len(f); i++ {
|
||||
out[i] = f[i+2] == "1"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -99,3 +99,92 @@ func TestKMTronicSetURL(t *testing.T) {
|
||||
}
|
||||
_ = d.Set(context.Background(), 1, false) // → /FF0100
|
||||
}
|
||||
|
||||
// Dingtian — the wire examples come straight from the IOT Relay Programming
|
||||
// Manual V1.9.8.1 §3.1/§3.2. The two traps pinned here: the URL relay index is
|
||||
// ZERO-based while the Device interface is 1-based, and a refusal (bad password
|
||||
// or session) answers HTTP 200 with a non-zero first field.
|
||||
|
||||
func TestDingtianStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/relay_cgi_load.cgi" {
|
||||
t.Errorf("unexpected status path %q", r.URL.Path)
|
||||
}
|
||||
// Manual's own example: ok, 4 relays, 1 on, 2 off, 3 on, 4 off.
|
||||
_, _ = w.Write([]byte("&0&4&1&0&1&0&"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2)
|
||||
st, err := d.Status(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The board said 4 relays even though 2 were configured — its count wins.
|
||||
want := []bool{true, false, true, false}
|
||||
if len(st) != len(want) {
|
||||
t.Fatalf("got %d relays, want %d", len(st), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if st[i] != want[i] {
|
||||
t.Errorf("relay %d = %v, want %v", i+1, st[i], want[i])
|
||||
}
|
||||
}
|
||||
if d.Count() != 4 {
|
||||
t.Errorf("Count() = %d, want 4 (taken from the board)", d.Count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingtianSetUsesZeroBasedIndexAndSession(t *testing.T) {
|
||||
var gotQuery, gotCookie string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQuery = r.URL.RawQuery
|
||||
gotCookie = r.Header.Get("Cookie")
|
||||
_, _ = w.Write([]byte("&0&0&2&1&0&"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "12345678", "4660", 4)
|
||||
if err := d.Set(context.Background(), 3, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "relay=2") {
|
||||
t.Errorf("relay 3 must go out as relay=2 (zero-based); query was %q", gotQuery)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "on=1") || !strings.Contains(gotQuery, "type=0") ||
|
||||
!strings.Contains(gotQuery, "pwd=4660") {
|
||||
t.Errorf("unexpected query %q", gotQuery)
|
||||
}
|
||||
if gotCookie != "session=12345678" {
|
||||
t.Errorf("session cookie = %q, want session=12345678", gotCookie)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingtianSetRefusalIsAnError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
// Manual §3.4.4: a bad session still answers 200 OK.
|
||||
_, _ = w.Write([]byte("&302&/&"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2)
|
||||
if err := d.Set(context.Background(), 1, true); err == nil {
|
||||
t.Fatal("a refused command answered HTTP 200 and was reported as success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayBase(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"192.168.1.5": "http://192.168.1.5",
|
||||
"192.168.1.5:8080": "http://192.168.1.5:8080",
|
||||
"https://relay.example.com": "https://relay.example.com",
|
||||
"https://relay.example.com/": "https://relay.example.com",
|
||||
"http://relay.example.com/sw/": "http://relay.example.com/sw",
|
||||
" relay.local ": "http://relay.local",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := relayBase(in); got != want {
|
||||
t.Errorf("relayBase(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -59,6 +60,13 @@ type Server struct {
|
||||
ln net.Listener
|
||||
conns map[net.Conn]struct{}
|
||||
closed bool
|
||||
|
||||
// ptt mirrors the last PTT state a client commanded via set_ptt. WSJT-X/JTDX
|
||||
// poll get_ptt DURING transmit to confirm the rig is keyed; if get_ptt reads
|
||||
// RX they conclude PTT failed and abort the over after a second or two. We
|
||||
// don't read PTT back from every backend, so echo what the client last set —
|
||||
// always consistent with its own command, and enough to satisfy the check.
|
||||
ptt atomic.Bool
|
||||
}
|
||||
|
||||
func New(port int, rig Rig, logf func(string, ...any)) *Server {
|
||||
@@ -228,10 +236,12 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
||||
return rprt(0), false
|
||||
|
||||
case "t", "\\get_ptt":
|
||||
// We do not read PTT back from every backend, and answering "transmitting"
|
||||
// wrongly would make a client hold off for ever. Reporting RX is the safe
|
||||
// direction: the worst case is a client that transmits when we said it
|
||||
// could, which is what it was going to do anyway.
|
||||
// Echo the last commanded PTT state. WSJT-X/JTDX poll this WHILE
|
||||
// transmitting to confirm the rig is keyed; answering a blanket "0" (RX)
|
||||
// made them decide PTT had failed and abort the over after ~1-2 s.
|
||||
if s.ptt.Load() {
|
||||
return "1\n", false
|
||||
}
|
||||
return "0\n", false
|
||||
case "T", "\\set_ptt":
|
||||
if len(args) < 1 {
|
||||
@@ -242,6 +252,7 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
||||
s.log("rigctld: set_ptt %v failed: %v", on, err)
|
||||
return rprt(-9), false
|
||||
}
|
||||
s.ptt.Store(on)
|
||||
return rprt(0), false
|
||||
|
||||
case "v", "\\get_vfo":
|
||||
|
||||
@@ -138,6 +138,28 @@ func TestHandleReportsBackendFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// get_ptt must echo the last commanded PTT state. WSJT-X/JTDX poll get_ptt while
|
||||
// transmitting to confirm the rig is keyed; a blanket "0" made them decide PTT
|
||||
// had failed and abort the over after a second or two.
|
||||
func TestGetPTTEchoesSetPTT(t *testing.T) {
|
||||
s := New(0, &fakeRig{}, nil)
|
||||
if got, _ := s.handle("t"); got != "0\n" {
|
||||
t.Fatalf("initial get_ptt = %q, want 0", got)
|
||||
}
|
||||
if got, _ := s.handle("T 1"); got != "RPRT 0\n" {
|
||||
t.Fatalf("set_ptt 1 = %q, want RPRT 0", got)
|
||||
}
|
||||
if got, _ := s.handle("t"); got != "1\n" {
|
||||
t.Fatalf("get_ptt after T 1 = %q, want 1 — client would abort TX", got)
|
||||
}
|
||||
if got, _ := s.handle("T 0"); got != "RPRT 0\n" {
|
||||
t.Fatalf("set_ptt 0 = %q, want RPRT 0", got)
|
||||
}
|
||||
if got, _ := s.handle("t"); got != "0\n" {
|
||||
t.Fatalf("get_ptt after T 0 = %q, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// dump_state is parsed POSITIONALLY by Hamlib clients: WSJT-X reads the first
|
||||
// line as the protocol version and refuses to continue if the block is short or
|
||||
// misshapen. Pinning its shape is what stops a well-meaning edit from silently
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Package dcu1 drives rotator controllers that speak the Hy-Gain DCU-1 protocol,
|
||||
// over a serial COM port (or a raw TCP socket, e.g. a serial-over-IP bridge).
|
||||
//
|
||||
// DCU-1 is used by the Hy-Gain DCU-1, the Idiom Press Rotor-EZ, Green Heron
|
||||
// controllers, and the RotorCard DXA (hamsupply) for Yaesu DXA rotors. It is a
|
||||
// DIFFERENT command set from Yaesu GS-232 (see internal/rotator/gs232):
|
||||
// semicolon-terminated, azimuth only.
|
||||
//
|
||||
// Commands (';' terminated — roundTrip appends the ';'):
|
||||
//
|
||||
// AP1nnn set the target bearing nnn (000-359)
|
||||
// AM1 rotate to the target (some controllers move on AP1 alone; AM1 is
|
||||
// harmless and makes the Rotor-EZ/DCU-1 variants that need it work)
|
||||
// AI1 query the current bearing → the reply carries the 3-digit azimuth
|
||||
//
|
||||
// The base DCU-1 set has no dedicated stop; Stop re-commands the current bearing,
|
||||
// which halts rotation.
|
||||
package dcu1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
const (
|
||||
dialTimeout = 3 * time.Second
|
||||
ioTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// Client is a stateless per-call sender, mirroring the gs232/pst/rotgenius idiom.
|
||||
// Exactly one of (Host, Port) or ComPort is used.
|
||||
type Client struct {
|
||||
Host string
|
||||
Port int
|
||||
ComPort string // serial transport: "COM5" etc.
|
||||
// Baud varies by controller (a Hy-Gain DCU-1 is 4800; Green Heron / RotorCard
|
||||
// can differ). Zero keeps 4800.
|
||||
Baud int
|
||||
}
|
||||
|
||||
// New returns a TCP Client (a serial-over-IP bridge in front of the controller).
|
||||
func New(host string, port int) *Client {
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
if port <= 0 || port > 65535 {
|
||||
port = 4001
|
||||
}
|
||||
return &Client{Host: host, Port: port}
|
||||
}
|
||||
|
||||
// NewSerial returns a Client over the controller's COM port.
|
||||
func NewSerial(comPort string, baud int) *Client {
|
||||
return &Client{ComPort: comPort, Baud: baud}
|
||||
}
|
||||
|
||||
// roundTrip opens a connection, sends one ';'-terminated command and (when
|
||||
// wantReply) reads until a 3-digit bearing is present. cmd must NOT carry the
|
||||
// ';'.
|
||||
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
||||
var conn io.ReadWriteCloser
|
||||
if c.ComPort != "" {
|
||||
baud := c.Baud
|
||||
if baud <= 0 {
|
||||
baud = 4800
|
||||
}
|
||||
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: baud})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open rotator %s @ %d baud: %w", c.ComPort, baud, err)
|
||||
}
|
||||
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
||||
conn = sp
|
||||
} else {
|
||||
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connect DCU-1 %s:%d: %w", c.Host, c.Port, err)
|
||||
}
|
||||
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
|
||||
conn = nc
|
||||
}
|
||||
defer conn.Close()
|
||||
if _, err := conn.Write([]byte(cmd + ";")); err != nil {
|
||||
return "", fmt.Errorf("send %q: %w", cmd, err)
|
||||
}
|
||||
if !wantReply {
|
||||
return "", nil
|
||||
}
|
||||
buf := make([]byte, 64)
|
||||
var sb strings.Builder
|
||||
deadline := time.Now().Add(ioTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
sb.Write(buf[:n])
|
||||
// The DCU-1 reply carries the bearing as three digits (framing varies —
|
||||
// ";nnn", "nnn;", "+0nnn"). Stop once we have them rather than on a
|
||||
// specific terminator, so any flavour reads cleanly.
|
||||
if azRe.MatchString(sb.String()) {
|
||||
break
|
||||
}
|
||||
}
|
||||
// A serial read that times out returns (0, nil) — keep polling until the
|
||||
// overall deadline; a real error ends the read.
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
line := strings.TrimSpace(sb.String())
|
||||
if line == "" {
|
||||
return "", fmt.Errorf("no reply to %q", cmd)
|
||||
}
|
||||
return line, nil
|
||||
}
|
||||
|
||||
// GoTo points the antenna at az (0-359): set the target, then rotate.
|
||||
func (c *Client) GoTo(az int) error {
|
||||
az = ((az % 360) + 360) % 360
|
||||
if _, err := c.roundTrip(fmt.Sprintf("AP1%03d", az), false); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.roundTrip("AM1", false)
|
||||
return err
|
||||
}
|
||||
|
||||
// Stop halts rotation. The base DCU-1 set has no stop command, so re-command the
|
||||
// current bearing — the controller stops when the target equals where it is.
|
||||
func (c *Client) Stop() error {
|
||||
az, _, err := c.Heading()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.roundTrip(fmt.Sprintf("AP1%03d", az), false)
|
||||
return err
|
||||
}
|
||||
|
||||
// azRe matches the 3-digit bearing in any of the DCU-1 reply framings.
|
||||
var azRe = regexp.MustCompile(`(\d{3})`)
|
||||
|
||||
// Heading queries the current azimuth. Returns the raw reply for diagnostics.
|
||||
func (c *Client) Heading() (az int, raw string, err error) {
|
||||
raw, err = c.roundTrip("AI1", true)
|
||||
if err != nil {
|
||||
return 0, raw, err
|
||||
}
|
||||
m := azRe.FindStringSubmatch(raw)
|
||||
if m == nil {
|
||||
return 0, raw, fmt.Errorf("unrecognised azimuth reply %q", raw)
|
||||
}
|
||||
az, _ = strconv.Atoi(m[1])
|
||||
return az % 360, raw, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user