Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e00488fad8 | ||
|
|
12c61dc35a | ||
|
|
0c0e8b06ba | ||
|
|
1bd3896ca7 | ||
|
|
7e6c0b4f7e | ||
|
|
2cde1a2c27 | ||
|
|
53100eb6c8 | ||
|
|
3b555219d2 | ||
|
|
ba35d4094c | ||
|
|
9bd6d988aa | ||
|
|
daabbc63c7 | ||
|
|
997bc81d5e | ||
|
|
3c59507bc3 | ||
|
|
721c43d569 | ||
|
|
25eda98612 | ||
|
|
0b909a4d63 | ||
|
|
37298afd77 | ||
|
|
5a77fdf68f | ||
|
|
31c898ad7f | ||
|
|
88c9756edf | ||
|
|
7b15b534cd | ||
|
|
8e7ecc3d51 | ||
|
|
7f9019c7bc | ||
|
|
a1a3bad682 | ||
|
|
1b746f2452 | ||
|
|
3db49e1213 | ||
|
|
605c934d33 | ||
|
|
dad762a0f1 | ||
|
|
97567d15dd | ||
|
|
fc080f3719 | ||
|
|
ce9ea10d68 | ||
|
|
dc94aa94b6 | ||
|
|
db7ac771b1 | ||
|
|
19134ea23c | ||
|
|
b12011fab7 | ||
|
|
d62cd10478 | ||
|
|
7de7cb96c3 | ||
|
|
dbc97ad61b | ||
|
|
7966b01900 | ||
|
|
f98e95fe9e | ||
|
|
392b93f089 | ||
|
|
9c459c754f | ||
|
|
ec967e29cf | ||
|
|
58e36667a7 | ||
|
|
77aba73096 | ||
|
|
da8f60a7b3 | ||
|
|
e8b5444fc2 | ||
|
|
08bc401681 | ||
|
|
7ac342e2d4 | ||
|
|
b244575937 | ||
|
|
c878742e50 | ||
|
|
13434ca36c | ||
|
|
6345710de5 | ||
|
|
0cab1c1e6b | ||
|
|
1c71495446 | ||
|
|
2c0158b75c | ||
|
|
a2b019b280 | ||
|
|
1fba7d2d57 | ||
|
|
490941f506 | ||
|
|
6bf759b6ae | ||
|
|
43e5088e96 | ||
|
|
582fa561b2 | ||
|
|
7f328d4eb5 | ||
|
|
b2eb6a4b1e | ||
|
|
c2db4c5f7e | ||
|
|
b246c4d10a | ||
|
|
6d4a110949 | ||
|
|
ffbbff80d6 | ||
|
|
bd2edf6624 | ||
|
|
6321948415 | ||
|
|
89e239d83f | ||
|
|
0e48ad7bb2 | ||
|
|
ad82c21dbc | ||
|
|
6f126802cd | ||
|
|
704b614c38 | ||
|
|
6eb6f1b339 | ||
|
|
dbd2fff121 | ||
|
|
dc6da67cbf | ||
|
|
ea6fb1c29c | ||
|
|
29267a865c | ||
|
|
e93d2d36a8 | ||
|
|
107c6fccf6 | ||
|
|
9350af9589 | ||
|
|
a9a6579a9a | ||
|
|
7795e13432 | ||
|
|
c9e98d5fdd | ||
|
|
72253f99fa | ||
|
|
f0d3bed8a2 | ||
|
|
30c9acf0d2 | ||
|
|
e3175ef6a3 | ||
|
|
441eb295c6 | ||
|
|
cb2f8aba72 | ||
|
|
7b9cda93bc | ||
|
|
8f64e3fee3 | ||
|
|
c4de5f2ea0 | ||
|
|
9172f504e5 | ||
|
|
7322e2ab66 | ||
|
|
29d79fb5d1 | ||
|
|
f6b70184a7 | ||
|
|
e4abcda94f | ||
|
|
284ee4ba7c | ||
|
|
10ef984962 | ||
|
|
bd8719ce99 | ||
|
|
5e38319379 | ||
|
|
7e7ad50f60 | ||
|
|
0f4e31853b | ||
|
|
c222468021 | ||
|
|
2fb4a4d0ce | ||
|
|
e4828482c4 | ||
|
|
8acfda4e42 | ||
|
|
db24c38e63 | ||
|
|
06868e9ed1 | ||
|
|
3ed643e072 | ||
|
|
f0f9898f7c | ||
|
|
73cae855ed | ||
|
|
de3a115ca6 | ||
|
|
1d7633484b | ||
|
|
5ee0ade54b | ||
|
|
e637f0814d | ||
|
|
1a169fdb4f | ||
|
|
3dc31697cd | ||
|
|
242a68080a | ||
|
|
8c7e1c1a3d | ||
|
|
47ddbed665 | ||
|
|
fd7ae77a61 | ||
|
|
3e794f57e0 | ||
|
|
30f74583ff | ||
|
|
f7b9aa2181 | ||
|
|
766b0f95a4 | ||
|
|
8bc4ed68d4 | ||
|
|
d534825e92 | ||
|
|
08d6492df1 | ||
|
|
a156b6ad10 | ||
|
|
3bb92f79b2 | ||
|
|
65cae0d822 | ||
|
|
b24f880d62 | ||
|
|
ef71ddb648 | ||
|
|
0143a84cee | ||
|
|
82721110ed | ||
|
|
aec363b152 | ||
|
|
9a21c936b1 | ||
|
|
d8f0fd7b4f | ||
|
|
4a942a04c3 | ||
|
|
ecae68ca79 | ||
|
|
9270b227b0 | ||
|
|
cff590fe8a | ||
|
|
4366083152 | ||
|
|
8865af35fd | ||
|
|
bfad51268b | ||
|
|
0e77e8d61f | ||
|
|
08d316b1e0 | ||
|
|
b8796369ba | ||
|
|
fa1c41388d | ||
|
|
d2cfad490a | ||
|
|
69d78ba8c0 | ||
|
|
9cf1984fb2 | ||
|
|
ee8fd32bf7 | ||
|
|
f357dad629 | ||
|
|
b3547364d4 | ||
|
|
965ed4c792 | ||
|
|
32dbfbd04e | ||
|
|
a930a4f02d | ||
|
|
f98a831195 | ||
|
|
9d21af9f7c | ||
|
|
0680181016 | ||
|
|
afd0714301 | ||
|
|
8855eaf05a | ||
|
|
2594697e00 | ||
|
|
cf5efea3e4 | ||
|
|
4689505fb9 | ||
|
|
23f425f95f | ||
|
|
2fd9845b37 | ||
|
|
e851bd97e0 | ||
|
|
b487ca1145 | ||
|
|
27b342a5e7 | ||
|
|
c1cd76bbcf | ||
|
|
4a28b51ade | ||
|
|
130c4e72e0 | ||
|
|
980c54a616 | ||
|
|
1201b44908 | ||
|
|
9c7983b13e | ||
|
|
01d0b8f22b | ||
|
|
dad71e9e4f | ||
|
|
fbe01bf19d | ||
|
|
fc79be7c05 | ||
|
|
4c2638a7e5 | ||
|
|
a501366cbf | ||
|
|
afde919e5b | ||
|
|
98c49f6bbc | ||
|
|
8b9c835ca1 | ||
|
|
37b436f543 | ||
|
|
656af9dae4 | ||
|
|
eff412a9a9 | ||
|
|
ff7227af5b | ||
|
|
3013a2ba08 | ||
|
|
de95a437bb | ||
|
|
85872f8379 | ||
|
|
55a643d9a4 | ||
|
|
949b92d7e7 | ||
|
|
37e9e288ba | ||
|
|
2b8ebd73cb | ||
|
|
f9f8380ece | ||
|
|
10e1504531 | ||
|
|
2e3464104d | ||
|
|
dc8f9c5099 | ||
|
|
08905a1680 | ||
|
|
fd939797dd | ||
|
|
a621a3ee62 | ||
|
|
ae4a56dac1 | ||
|
|
fdc2378191 | ||
|
|
5388f76733 | ||
|
|
ceb88d2e29 | ||
|
|
ec3e60e47b | ||
|
|
5c6df90526 | ||
|
|
deeb654482 | ||
|
|
8e0865a71f | ||
|
|
d4d22eb4b2 | ||
|
|
592dd08835 | ||
|
|
c6294d9eb3 | ||
|
|
848ce68ec5 | ||
|
|
95e57fb812 | ||
|
|
4441b16699 | ||
|
|
5fd3b5b688 | ||
|
|
f582bbab3a | ||
|
|
b40dc8a597 | ||
|
|
14bb92fac0 | ||
|
|
9887b2aa78 | ||
|
|
2adf252942 | ||
|
|
660653ce14 | ||
|
|
f3b607b15a | ||
|
|
ae5aa7b547 | ||
|
|
f50bbc005c | ||
|
|
e8dfa0eaaf | ||
|
|
b53c56d508 | ||
|
|
67c85be23c | ||
|
|
e41a3a4c1c | ||
|
|
e4276ff66e | ||
|
|
1e87081058 | ||
|
|
12b0a861e0 | ||
|
|
712d83a012 | ||
|
|
bd540ef18b | ||
|
|
2f2fced15c | ||
|
|
97ae05d688 | ||
|
|
df1d2767cd | ||
|
|
ed099a660e | ||
|
|
6536d140ba | ||
|
|
6a6b7ad6c2 | ||
|
|
01a23ccb77 | ||
|
|
9b8168370f | ||
|
|
b2b93e2839 | ||
|
|
f9113b6ad3 | ||
|
|
e168ab4148 | ||
|
|
efa711af78 |
@@ -1,6 +1,6 @@
|
||||
# OpsLog
|
||||
|
||||
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
|
||||
<a href="https://discord.gg/tmVPdQ5A8" target="_blank">
|
||||
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
|
||||
</a>
|
||||
|
||||
@@ -39,6 +39,9 @@ Developed by **F4BPO**.
|
||||
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
||||
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
|
||||
ClubLog DXpedition date overrides.
|
||||
- **Editable lists** for bands, modes (with their default RST) and **satellites**
|
||||
— the satellite name on the entry form is offered as a dropdown, because
|
||||
SAT_NAME is compared character for character by the awards and by LoTW.
|
||||
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
||||
from cty/QRZ/ClubLog, bulk send to QSL services. A live **selection count**, a
|
||||
**Select all / Unselect all** toggle, and a row limit that keeps the full log
|
||||
@@ -57,11 +60,13 @@ Developed by **F4BPO**.
|
||||
|
||||
- **Main view = two configurable panes** (per profile, Settings → General →
|
||||
*Main view*): great-circle map, locator (street) map, the cluster grid, the
|
||||
worked-before grid, recent QSOs, the **FlexRadio controls**, the **Icom
|
||||
console** or the **Net control** panel.
|
||||
worked-before grid, recent QSOs, the **FT decodes**, or any of the radio
|
||||
consoles — **FlexRadio**, **Icom**, **Yaesu**, **Elecraft K3/K4**, **SunSDR
|
||||
(TCI)** — and the **Net control** panel. Up to four panes, in columns or a
|
||||
quadrant.
|
||||
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
||||
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
||||
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
||||
basemaps (Light / Topo / Street / Satellite — Esri tiles, key-free and
|
||||
labelled) and the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
||||
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by
|
||||
**PstRotator** (UDP), a **4O3A Rotator Genius** (native TCP) or a **microHAM
|
||||
ARCO** controlled natively — no PstRotator needed — over the **LAN** or **USB**
|
||||
@@ -87,8 +92,10 @@ Developed by **F4BPO**.
|
||||
|
||||
## CAT control
|
||||
|
||||
Four native backends (Settings → CAT), each with auto-reconnect and a fast,
|
||||
non-blocking connect so a powered-off radio never freezes the app:
|
||||
Six native backends (Settings → CAT), each with auto-reconnect and a fast,
|
||||
non-blocking connect so a powered-off radio never freezes the app. The settings
|
||||
ask two questions rather than one — **which radio**, then **how it is
|
||||
connected** — and only offer the links that radio actually has:
|
||||
|
||||
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
||||
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
||||
@@ -98,8 +105,17 @@ non-blocking connect so a powered-off radio never freezes the app:
|
||||
- **Icom CI-V** — native, over the radio's **USB** port *or* over the internet
|
||||
via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or
|
||||
Remote Utility needed.
|
||||
- **TCI** (WebSocket) — SunSDR / ExpertSDR2 and any TCI-compatible server:
|
||||
freq / mode / PTT / split, plus optional panorama spots.
|
||||
- **Kenwood / Elecraft** — native ASCII CAT over USB or an RS232-to-Ethernet
|
||||
bridge, for the K3 / K4 and the Kenwood dialect they share.
|
||||
- **Yaesu** — native ASCII CAT (FA/FB/MD/VS…), which replaces OmniRig for an
|
||||
FTDX and gives the Yaesu console below.
|
||||
- **TCI** (WebSocket) — SunSDR / ExpertSDR3 and any TCI-compatible server:
|
||||
freq / mode / PTT / split, panorama spots, **audio in both directions** and a
|
||||
full console. See *TCI audio* below.
|
||||
|
||||
**CAT sharing:** OpsLog can hand the radio on to other programs while it holds
|
||||
the port — a **Hamlib NET rigctl** server (WSJT-X, JTDX, MSHV, fldigi…) or a
|
||||
**TCI** server. One or the other, on a port you choose.
|
||||
|
||||
Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred from
|
||||
the frequency. **Per-band Flex RX/TX antennas** can be configured and are applied
|
||||
@@ -124,6 +140,32 @@ Shown only when the CAT backend is a FlexRadio:
|
||||
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
|
||||
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
||||
|
||||
### Elecraft K3 / K4 control tab
|
||||
|
||||
Shown for the Elecraft and Kenwood backends: power, AF/RF gain, mic gain,
|
||||
squelch, preamp/attenuator, NB/NR/AGC, filter widths (200 Hz to 4.0 kHz, the
|
||||
K3 maximum and the one FT8 wants), antenna selection, MOX and the ATU
|
||||
(tap to tune, hold for in/out), RIT/XIT with ±10 / ±100 steps, key speed, and
|
||||
live S / power / SWR meters. The SWR is read from the radio's own `SW;`.
|
||||
|
||||
### Yaesu control tab
|
||||
|
||||
Shown for the Yaesu backend: power, mic gain, AGC, NB/NR, preamp/attenuator,
|
||||
filter width, RIT/XIT and the meters — the same shape as the others, driven by
|
||||
the FTDX's own CAT set.
|
||||
|
||||
### SunSDR console (TCI)
|
||||
|
||||
Shown for the TCI backend. Drive and tune drive, mic gain, TUNE, power and SWR
|
||||
while transmitting, volume, mute, squelch and its threshold, NB / NR / ANF,
|
||||
APF in CW, AGC speed, filter widths **offered per mode**, RIT and XIT, and the
|
||||
VFO lock. Levels can be dragged, scrolled, stepped or typed. The S-meter reads
|
||||
real dBm, so its S-units are arithmetic rather than a calibration guess.
|
||||
|
||||
It costs nothing to keep open: TCI **pushes** every setting as it changes —
|
||||
including changes made in the radio's own window, which the console follows
|
||||
without asking anything.
|
||||
|
||||
### Icom control tab
|
||||
|
||||
Shown when the CAT backend is Icom (USB or network). A full RS-BA1-style console:
|
||||
@@ -169,6 +211,17 @@ as Mumble.)
|
||||
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
|
||||
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
||||
|
||||
### TCI audio — no virtual cable
|
||||
|
||||
A SunSDR carries its audio on the same WebSocket as its commands, so OpsLog can
|
||||
take it directly. Pick **Radio (TCI network audio)** as the *From radio* or
|
||||
*To radio* device and the QSO recorder and the voice keyer work with no virtual
|
||||
audio cable, no second sound card and nothing to set in the Windows mixer.
|
||||
|
||||
Transmit needs no setting up in ExpertSDR3 either: the audio source is named on
|
||||
each key-down, so it works in SSB as well as the digital modes, and the
|
||||
microphone stays the source for every transmission that is not a voice message.
|
||||
|
||||
## Amplifiers & switches
|
||||
|
||||
- **Amplifiers** — configure **one or several** amps (Settings → Amplifier is a
|
||||
@@ -182,12 +235,24 @@ as Mumble.)
|
||||
**network** (RS232-to-Ethernet bridge) — operate/standby, ON/OFF,
|
||||
Low / Mid / High output level, an output-power bar and live status (band,
|
||||
SWR, PA current, temperature, warnings/alarms).
|
||||
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) over **USB** or the **network**
|
||||
- **Acom** (500S / 600S / 700S / 1200S / 2020S) over **USB** or the **network**
|
||||
(RS232-to-Ethernet bridge) — operate/standby/off and live telemetry (forward
|
||||
& reflected power, SWR, PA temperature, band, fan, faults). Power-ON works
|
||||
over a serial cable that wires the DTR/RTS lines.
|
||||
- **Elecraft KPA500 / KPA1500** over **USB** (serial) or — on the KPA1500 —
|
||||
over the **network**. Operate/standby, on/off, forward power, SWR,
|
||||
temperature, supply voltage and current, the band, and faults named in plain
|
||||
words rather than as a code. The amplifier follows the radio's band by
|
||||
itself, on the link already open — it has a band command of its own, so no
|
||||
second serial port is involved.
|
||||
|
||||
An amplifier that takes its band from a transceiver instead (Acom, SPE) can be
|
||||
followed a second way: OpsLog answers its frequency polls as a Kenwood rig on
|
||||
a **second COM port**, independent of the control link.
|
||||
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
||||
antenna-switch widget.
|
||||
antenna-switch widget, and an option to write the **selected antenna into
|
||||
MY_ANTENNA** under the name the switch itself carries.
|
||||
- **Tuner Genius XL** (4O3A) over TCP — SWR and power, TUNE / BYPASS / OPERATE.
|
||||
- **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**,
|
||||
**Ultrabeam** element control and **relay boards** — WebSwitch 1216H, KMTronic,
|
||||
**Denkovi** USB (FT245 D2XX bit-bang, 4 or 8 relays) and generic USB-serial
|
||||
@@ -204,8 +269,18 @@ as Mumble.)
|
||||
reference assignment, live reference detection on call entry, **reference-list
|
||||
import** for totals/names, and a **Rescan** that re-pulls the logbook (picks up
|
||||
fresh LoTW/QRZ confirmations).
|
||||
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL — upload
|
||||
and **confirmation download** (which auto-refreshes the award stats).
|
||||
- **Award statistics** by band and by mode class, with the **DXCC Challenge**
|
||||
worked out where it can be compared to LoTW's own figure — it counts confirmed
|
||||
band-slots on ten bands, and 60 m is not one of them, which is the whole of the
|
||||
usual discrepancy. The band columns follow the bands you actually have contacts
|
||||
on, up to and beyond 23 cm.
|
||||
- **Russian districts (RDA):** an offline district database that knows where a
|
||||
callsign was **on the day of the contact**, a bulk fill for existing QSOs, and
|
||||
a comparison against the district an imported log carries — each disagreement
|
||||
settled on its own row, with the choice written into the contact.
|
||||
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL,
|
||||
**Cloudlog / Wavelog** and **HAMLOG.online** — upload and **confirmation
|
||||
download** (which auto-refreshes the award stats).
|
||||
- **QSL Card Designer** (see below).
|
||||
- **E-mail eQSL:** right-click a QSO → *Send eQSL by e-mail* via the configured
|
||||
SMTP account. (Outlook/Hotmail disable basic-auth SMTP — use Gmail with an app
|
||||
@@ -266,6 +341,10 @@ only writes to the DB — it is not a web server.
|
||||
- **UDP emitters:** push the current frequency to **PstRotator**, radio info in
|
||||
**N1MM `RadioInfo`** format, or an **ADIF record on each logged QSO** — so
|
||||
external tools (rotator control, digital apps, other loggers) stay in sync.
|
||||
- **CAT sharing:** a **Hamlib NET rigctl** or **TCI** server, so WSJT-X, JTDX,
|
||||
MSHV, fldigi and the rest still reach the radio while OpsLog holds the port.
|
||||
- **WSJT-X / JTDX / MSHV** decodes over UDP: the heard stations feed the band
|
||||
map, the alerts and — on a FlexRadio — the panadapter.
|
||||
|
||||
## Other
|
||||
|
||||
@@ -309,6 +388,19 @@ commonly-worked DXCC entities.
|
||||
|
||||
---
|
||||
|
||||
## Importing a log
|
||||
|
||||
- **ADIF import** with duplicate handling (skip / update / import anyway) and an
|
||||
optional field mapping for exports that use their own names.
|
||||
- Optionally recompute country / continent / DXCC / zones from **cty.dat** and
|
||||
the **ClubLog date-ranged exceptions** — which know that a callsign belonged to
|
||||
a different entity on an older date.
|
||||
- **Deleted DXCC entities are left alone.** cty.dat only knows where a callsign
|
||||
is *today*: R1MVI resolves to European Russia now, but a 2004 contact was Malyj
|
||||
Vysotskij — an entity the ARRL deleted in 2012 and which can never be worked
|
||||
again. Where a record names one of the sixty-odd deleted entities, its own
|
||||
number is kept.
|
||||
|
||||
## Data & storage
|
||||
|
||||
- **Config** (settings, profiles, rigs/antennas, cluster nodes, lookup cache,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
// MY_ANTENNA from the Antenna Genius.
|
||||
//
|
||||
// A station with a switch has a truth the log does not: which antenna is
|
||||
// actually connected right now. The working conditions hold what was PLANNED
|
||||
// for the band — the default antenna ticked for 20 m — and that is right until
|
||||
// the operator throws the switch, at which point the log quietly keeps claiming
|
||||
// the other antenna for the rest of the session.
|
||||
//
|
||||
// So, as an option: when the Antenna Genius knows which antenna is selected,
|
||||
// its name wins. The name is the one configured on the device, because that is
|
||||
// the name the operator gave it and the one they will look for in the log.
|
||||
//
|
||||
// WHICH PORT is the whole difficulty. An Antenna Genius has two, A and B, and a
|
||||
// FlexRadio has two transmit antenna jacks, ANT1 and ANT2. The QSO was made on
|
||||
// exactly one of them, and stamping the wrong port's antenna would be worse
|
||||
// than stamping the band default — a wrong answer that looks authoritative. The
|
||||
// radio's own TX antenna selection is what decides.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/antgenius"
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
const (
|
||||
keyAntGeniusMyAnt = "antgenius.my_antenna" // use the selected antenna as MY_ANTENNA
|
||||
keyAntGeniusPortForA1 = "antgenius.port_for_ant1" // which AG port ANT1 is wired to (1=A, 2=B)
|
||||
)
|
||||
|
||||
// antGeniusPortFor maps the radio's transmit antenna jack to an Antenna Genius
|
||||
// port.
|
||||
//
|
||||
// The wiring is the station's, not something that can be read from either
|
||||
// device: ANT1 usually goes to port A and ANT2 to port B, which is what the
|
||||
// setting defaults to, but a station wired the other way round would otherwise
|
||||
// log every QSO with the other antenna's name.
|
||||
func (a *App) antGeniusPortFor(txAnt string) int {
|
||||
ant1Port := 1
|
||||
if a.settingOr(keyAntGeniusPortForA1, "1") == "2" {
|
||||
ant1Port = 2
|
||||
}
|
||||
other := 3 - ant1Port
|
||||
switch strings.ToUpper(strings.TrimSpace(txAnt)) {
|
||||
case "ANT1", "ANT 1", "1", "A":
|
||||
return ant1Port
|
||||
case "ANT2", "ANT 2", "2", "B":
|
||||
return other
|
||||
}
|
||||
return 0 // XVTR, a rig with one jack, or nothing reported: no port to name
|
||||
}
|
||||
|
||||
// antGeniusAntennaName returns the name of the antenna currently selected on
|
||||
// the port the radio is transmitting through, or "" when it cannot be told.
|
||||
//
|
||||
// Deliberately silent rather than approximate. Every "" here means the log
|
||||
// keeps the band default it would have had anyway, which is a defensible
|
||||
// answer; a guessed port is not.
|
||||
func (a *App) antGeniusAntennaName() string {
|
||||
if a.antgenius == nil {
|
||||
return ""
|
||||
}
|
||||
st := a.antgenius.GetStatus()
|
||||
if !st.Connected {
|
||||
return ""
|
||||
}
|
||||
// The jack in use comes from the FlexRadio's own state — TXAnt lives on the
|
||||
// Flex panel state, not on the backend-agnostic RigState, because only a
|
||||
// Flex has named antenna jacks to report.
|
||||
txAnt := ""
|
||||
if a.cat != nil {
|
||||
if fs, ok := a.cat.FlexState(); ok {
|
||||
txAnt = fs.TXAnt
|
||||
}
|
||||
}
|
||||
port := a.antGeniusPortFor(txAnt)
|
||||
if port == 0 {
|
||||
// One switch port in use and no ambiguity about which: a station whose
|
||||
// radio has a single jack still deserves the name. Two ports carrying
|
||||
// different antennas with nothing to choose between them does not.
|
||||
if st.PortA > 0 && st.PortB == 0 {
|
||||
port = 1
|
||||
} else if st.PortB > 0 && st.PortA == 0 {
|
||||
port = 2
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
idx := st.PortA
|
||||
if port == 2 {
|
||||
idx = st.PortB
|
||||
}
|
||||
if idx <= 0 {
|
||||
return ""
|
||||
}
|
||||
return antGeniusNameOf(st, idx)
|
||||
}
|
||||
|
||||
// antGeniusNameOf looks an antenna index up in the device's own list.
|
||||
func antGeniusNameOf(st antgenius.Status, idx int) string {
|
||||
for _, ant := range st.Antennas {
|
||||
if ant.Index == idx {
|
||||
return strings.TrimSpace(ant.Name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// applyAntGeniusAntenna overrides MY_ANTENNA with the switch's selection, when
|
||||
// the option is on.
|
||||
//
|
||||
// Called at log time rather than while the entry form is open: an operator who
|
||||
// changes antenna mid-QSO is telling us what the contact was actually made on,
|
||||
// and the value that matters is the one at the moment it is logged.
|
||||
func (a *App) applyAntGeniusAntenna(myAntenna *string) {
|
||||
if myAntenna == nil || a.settingOr(keyAntGeniusMyAnt, "") != "1" {
|
||||
return
|
||||
}
|
||||
name := a.antGeniusAntennaName()
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
if *myAntenna != name {
|
||||
applog.Printf("antgenius: MY_ANTENNA %q → %q (the antenna selected on the switch)", *myAntenna, name)
|
||||
}
|
||||
*myAntenna = name
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/antgenius"
|
||||
)
|
||||
|
||||
// The switch has two ports and the radio two antenna jacks; a QSO was made
|
||||
// through exactly one of them. Naming the wrong port's antenna would be worse
|
||||
// than naming the band default — it is a wrong answer that looks authoritative.
|
||||
func TestAntennaNameComesFromTheDeviceList(t *testing.T) {
|
||||
st := antgenius.Status{
|
||||
Connected: true,
|
||||
PortA: 2,
|
||||
PortB: 5,
|
||||
Antennas: []antgenius.Antenna{
|
||||
{Index: 2, Name: "OB11-5 20/15/10"},
|
||||
{Index: 5, Name: "Vertical 80m"},
|
||||
},
|
||||
}
|
||||
if got := antGeniusNameOf(st, 2); got != "OB11-5 20/15/10" {
|
||||
t.Fatalf("port A antenna = %q", got)
|
||||
}
|
||||
if got := antGeniusNameOf(st, 5); got != "Vertical 80m" {
|
||||
t.Fatalf("port B antenna = %q", got)
|
||||
}
|
||||
// An index the device never described has no name to give, and inventing
|
||||
// one ("Antenna 7") would put a label in the log that exists nowhere else.
|
||||
if got := antGeniusNameOf(st, 7); got != "" {
|
||||
t.Fatalf("unknown index produced %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The jack-to-port wiring is the station's own. Defaulting ANT1 to port A is a
|
||||
// convention, not a fact, so it is a setting — and the mapping has to hold both
|
||||
// ways round.
|
||||
func TestJackToPortMapping(t *testing.T) {
|
||||
app := &App{}
|
||||
// No settings store: settingOr falls back, which is ANT1 → port A.
|
||||
if got := app.antGeniusPortFor("ANT1"); got != 1 {
|
||||
t.Fatalf("ANT1 mapped to port %d, want A(1)", got)
|
||||
}
|
||||
if got := app.antGeniusPortFor("ANT2"); got != 2 {
|
||||
t.Fatalf("ANT2 mapped to port %d, want B(2)", got)
|
||||
}
|
||||
// A jack that is neither — a transverter port, or a rig that reports
|
||||
// nothing — has no port to name, and saying so beats guessing.
|
||||
if got := app.antGeniusPortFor("XVTR"); got != 0 {
|
||||
t.Fatalf("XVTR mapped to port %d, want none", got)
|
||||
}
|
||||
if got := app.antGeniusPortFor(""); got != 0 {
|
||||
t.Fatalf("an unreported jack mapped to port %d, want none", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
// The voice keyer, through an Icom's network link — the Icom face of what
|
||||
// app_tci_dvk.go does for a SunSDR: selecting the radio as the "To radio"
|
||||
// output hands messages to the 50003 audio session instead of a sound card.
|
||||
// The same PTT before and after, the same gain, the same files.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/audio"
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
// icomTXPlayer hands one message to the radio. Fetched on the CAT goroutine,
|
||||
// played off it — a ten-second message must not freeze frequency, mode and
|
||||
// PTT handling for ten seconds (see tciTXPlayer, which set the pattern).
|
||||
func (a *App) icomTXPlayer(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("CAT not initialized")
|
||||
}
|
||||
type txPlayer interface {
|
||||
PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error
|
||||
}
|
||||
var player txPlayer
|
||||
err := a.cat.IcomDo(func(ic cat.IcomController) error {
|
||||
p, ok := ic.(txPlayer)
|
||||
if !ok {
|
||||
return fmt.Errorf("this radio cannot take transmit audio over its CAT link")
|
||||
}
|
||||
player = p
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return player.PlayTXAudio(pcm, rate, ch, bits, stop)
|
||||
}
|
||||
|
||||
// installIcomTXPlayer offers the network Icom as an audio output, or withdraws
|
||||
// it. Withdrawing matters as much as offering — see installTCITXPlayer.
|
||||
func (a *App) installIcomTXPlayer(on bool) {
|
||||
if !on {
|
||||
return // the reloadCAT preamble already cleared the network player
|
||||
}
|
||||
audio.SetNetworkPlayer(a.icomTXPlayer)
|
||||
applog.Printf("icom net: the radio is available as an audio output — the voice keyer can play to it without a cable")
|
||||
}
|
||||
@@ -24,6 +24,17 @@ func (a *App) GetKenwoodState() cat.KenwoodTXState {
|
||||
return st
|
||||
}
|
||||
|
||||
// SetKenwoodPanelMode sets the operating mode from the panel's mode row —
|
||||
// CW / USB / LSB / DATA (soundcard, DT0) / RTTY (FSK D, DT2).
|
||||
func (a *App) SetKenwoodPanelMode(mode string) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPanelMode(mode) })
|
||||
}
|
||||
|
||||
// SetKenwoodRITOffset sets the RIT/XIT offset to an absolute value in Hz.
|
||||
func (a *App) SetKenwoodRITOffset(hz int) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodRITOffset(hz) })
|
||||
}
|
||||
|
||||
func (a *App) SetKenwoodPower(w int) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPower(w) })
|
||||
}
|
||||
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
package main
|
||||
|
||||
// Several radios, and the one that is on the air.
|
||||
//
|
||||
// Until now the answer to "I have two rigs" was "make two profiles", which is a
|
||||
// heavy instrument for a light question: a profile carries the whole station —
|
||||
// logbook, callsign, awards, cluster, macros — and switching one to change
|
||||
// which radio is connected takes all of that with it, including a logbook
|
||||
// change if the profiles point at different databases.
|
||||
//
|
||||
// So radios are a LIST, the way amplifiers already are, and switching is one
|
||||
// click on the CAT chip in the status bar. Nothing else about the station
|
||||
// moves.
|
||||
//
|
||||
// The storage deliberately keeps the ACTIVE radio in the settings keys the rest
|
||||
// of OpsLog already reads (cat.backend, cat.icom_port, …). Switching writes the
|
||||
// chosen entry into those keys and reloads the link, so every consumer — the
|
||||
// consoles, the CAT sharing, the band-follow, the panels — sees exactly what it
|
||||
// saw before and needed no change at all. The list is a second store beside it,
|
||||
// not a replacement for it.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
const (
|
||||
// keyRadiosList holds the saved radios as JSON.
|
||||
keyRadiosList = "cat.radios.json"
|
||||
// keyRadioActive is the id of the one currently connected.
|
||||
keyRadioActive = "cat.radios.active"
|
||||
)
|
||||
|
||||
// RadioConfig is one saved radio: a name and the CAT settings that reach it.
|
||||
type RadioConfig struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// Settings is the whole CAT configuration for this radio — the same shape
|
||||
// the CAT panel has always edited, so a saved radio is exactly "what the
|
||||
// settings said the day it was saved".
|
||||
Settings CATSettings `json:"settings"`
|
||||
// MyRig is what goes into MY_RIG on a QSO made with this radio.
|
||||
//
|
||||
// It belongs here rather than in Operating conditions once there is more
|
||||
// than one rig: the operating conditions describe a PLAN — "on 20 m I use
|
||||
// the beam and the 7300" — while this is the fact of which radio is keying.
|
||||
// Left empty, nothing changes: the old chain (band default, then the
|
||||
// profile's rig) answers exactly as it did.
|
||||
MyRig string `json:"my_rig"`
|
||||
}
|
||||
|
||||
// radioLabel is what the status bar shows when the operator never named one.
|
||||
func radioLabel(c RadioConfig, i int) string {
|
||||
if n := strings.TrimSpace(c.Name); n != "" {
|
||||
return n
|
||||
}
|
||||
if b := strings.TrimSpace(c.Settings.Backend); b != "" {
|
||||
return strings.ToUpper(b)
|
||||
}
|
||||
return fmt.Sprintf("Radio %d", i+1)
|
||||
}
|
||||
|
||||
// GetRadios returns the saved radios.
|
||||
//
|
||||
// When nothing was ever saved, the CURRENT settings are presented as the single
|
||||
// entry — so an operator who has been running one rig for a year opens the list
|
||||
// and finds it there, rather than an empty box suggesting their configuration
|
||||
// has been lost. It is persisted on the next save, not here: reading a list
|
||||
// should not write one.
|
||||
func (a *App) GetRadios() ([]RadioConfig, error) {
|
||||
if a.settings == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
raw := a.settingOr(keyRadiosList, "")
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
var list []RadioConfig
|
||||
if err := json.Unmarshal([]byte(raw), &list); err == nil && len(list) > 0 {
|
||||
return list, nil
|
||||
}
|
||||
}
|
||||
cur, err := a.GetCATSettings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []RadioConfig{{ID: "radio-1", Name: "", Settings: cur}}, nil
|
||||
}
|
||||
|
||||
// SaveRadios stores the list, giving an id to anything new.
|
||||
func (a *App) SaveRadios(list []RadioConfig) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
for i := range list {
|
||||
if strings.TrimSpace(list[i].ID) == "" {
|
||||
list[i].ID = fmt.Sprintf("radio-%d-%d", time.Now().Unix(), i)
|
||||
}
|
||||
}
|
||||
b, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.settings.Set(a.ctx, keyRadiosList, string(b))
|
||||
}
|
||||
|
||||
// ActiveRadioID is the id of the radio currently on the air, or the first one
|
||||
// when nothing was ever chosen.
|
||||
func (a *App) ActiveRadioID() string {
|
||||
id := strings.TrimSpace(a.settingOr(keyRadioActive, ""))
|
||||
list, err := a.GetRadios()
|
||||
if err != nil || len(list) == 0 {
|
||||
return id
|
||||
}
|
||||
for _, r := range list {
|
||||
if r.ID == id {
|
||||
return id
|
||||
}
|
||||
}
|
||||
// The stored id names a radio that has since been deleted. The first one is
|
||||
// a better answer than an empty selection: the CAT link is up, and it is up
|
||||
// on SOMETHING.
|
||||
return list[0].ID
|
||||
}
|
||||
|
||||
// RadioListEntry is what the status-bar menu needs: enough to draw a row.
|
||||
//
|
||||
// Name is what the OPERATOR typed, empty when they typed nothing; Label is what
|
||||
// to draw when there is no better idea. The two are separate because the caller
|
||||
// has a better idea than we do: the status bar knows what the radio calls
|
||||
// itself over CAT, and "FTDX10" beats "Radio 2" — but only where the operator
|
||||
// has not given it a name of their own, which beats both.
|
||||
type RadioListEntry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Backend string `json:"backend"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// ListRadios is the status bar's view of the list.
|
||||
func (a *App) ListRadios() []RadioListEntry {
|
||||
list, err := a.GetRadios()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
active := a.ActiveRadioID()
|
||||
out := make([]RadioListEntry, 0, len(list))
|
||||
for i, r := range list {
|
||||
out = append(out, RadioListEntry{
|
||||
ID: r.ID, Name: strings.TrimSpace(r.Name), Label: radioLabel(r, i),
|
||||
Backend: r.Settings.Backend, Active: r.ID == active,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetActiveRadio connects the given radio and leaves the rest of the station
|
||||
// alone.
|
||||
//
|
||||
// The chosen entry's settings become THE CAT settings — SaveCATSettings writes
|
||||
// them and restarts the link — so switching rigs is the same operation as
|
||||
// editing the CAT panel and pressing Save, which is a path that already works
|
||||
// everywhere it needs to.
|
||||
func (a *App) SetActiveRadio(id string) error {
|
||||
list, err := a.GetRadios()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
for i, r := range list {
|
||||
if r.ID != id {
|
||||
continue
|
||||
}
|
||||
// Persisted BEFORE the link is rebuilt: reloadCAT can take a moment on a
|
||||
// radio that is switched off, and an operator who closes OpsLog during
|
||||
// that moment should still come back to the rig they chose.
|
||||
a.setSetting(keyRadioActive, id)
|
||||
// The list is written back as well when it was only ever implicit, so
|
||||
// the first switch is also what makes the list real.
|
||||
if strings.TrimSpace(a.settingOr(keyRadiosList, "")) == "" {
|
||||
_ = a.SaveRadios(list)
|
||||
}
|
||||
applog.Printf("cat: switching to %q (%s)", radioLabel(r, i), r.Settings.Backend)
|
||||
return a.SaveCATSettings(r.Settings)
|
||||
}
|
||||
return fmt.Errorf("no radio with id %q", id)
|
||||
}
|
||||
|
||||
// syncActiveRadio writes the settings just saved into the active entry of the
|
||||
// list, so the list and the live configuration never disagree.
|
||||
//
|
||||
// Only when a list actually exists: an operator with one radio who has never
|
||||
// opened the list has nothing to keep in step, and writing one here would
|
||||
// create a list as a side effect of saving the CAT panel.
|
||||
func (a *App) syncActiveRadio(s CATSettings) {
|
||||
if a.settings == nil || strings.TrimSpace(a.settingOr(keyRadiosList, "")) == "" {
|
||||
return
|
||||
}
|
||||
list, err := a.GetRadios()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
id := a.ActiveRadioID()
|
||||
for i := range list {
|
||||
if list[i].ID == id {
|
||||
list[i].Settings = s
|
||||
_ = a.SaveRadios(list)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// activeRadioMyRig is the MY_RIG of the radio currently connected, or "".
|
||||
//
|
||||
// Consulted BEFORE the per-band default, and deliberately: the band default is
|
||||
// what the operator planned to use on that band, this is which radio is
|
||||
// actually on the air. When they disagree — a second rig borrowed for one
|
||||
// evening on 20 m — the one that is transmitting is the true answer.
|
||||
func (a *App) activeRadioMyRig() string {
|
||||
if a.settings == nil || strings.TrimSpace(a.settingOr(keyRadiosList, "")) == "" {
|
||||
return "" // no list was ever made: nothing to say, nothing changes
|
||||
}
|
||||
list, err := a.GetRadios()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
id := a.ActiveRadioID()
|
||||
for _, r := range list {
|
||||
if r.ID == id {
|
||||
return strings.TrimSpace(r.MyRig)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ActiveRadioMyRig exposes the connected radio's MY_RIG to the frontend, which
|
||||
// pre-fills the entry form's My-station fields on every band change. Without
|
||||
// this the per-band default landed in the field FIRST, and the log-time
|
||||
// priority (the radio that is keying beats the radio that was planned) never
|
||||
// ran — the field was no longer empty by the time the backend looked.
|
||||
func (a *App) ActiveRadioMyRig() string {
|
||||
return a.activeRadioMyRig()
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
// The voice keyer, through the radio's own link.
|
||||
//
|
||||
// Selecting the radio as the "To radio" output makes the voice keyer hand its
|
||||
// messages to the CAT backend instead of a sound card. Everything around it is
|
||||
// unchanged — the same PTT before and after, the same gain, the same files —
|
||||
// which is the point: the audio takes a different road, not a different route.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/audio"
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
// tciTXPlayer hands one message to the radio.
|
||||
//
|
||||
// The controller is fetched on the CAT goroutine and the message is then played
|
||||
// OFF it. Playing on it would hold that goroutine for the length of the
|
||||
// message, and everything else about the rig — frequency, mode, PTT state —
|
||||
// goes through the same place: a ten-second call would freeze the display and
|
||||
// the antenna following for ten seconds. The TCI backend serialises its own
|
||||
// writes, so this is safe to call from here.
|
||||
func (a *App) tciTXPlayer(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("CAT not initialized")
|
||||
}
|
||||
type txPlayer interface {
|
||||
PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error
|
||||
}
|
||||
var player txPlayer
|
||||
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
||||
p, ok := t.(txPlayer)
|
||||
if !ok {
|
||||
return fmt.Errorf("this radio cannot take transmit audio over its CAT link")
|
||||
}
|
||||
player = p
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return player.PlayTXAudio(pcm, rate, ch, bits, stop)
|
||||
}
|
||||
|
||||
// applyTCITXSource tells the radio where to take its transmit audio from,
|
||||
// following the "To radio" device.
|
||||
//
|
||||
// This is the setting an operator would otherwise have to find in ExpertSDR3
|
||||
// and set again for every mode, because it is remembered per mode there. It is
|
||||
// the third argument of TRX, so OpsLog can simply say it each time it keys —
|
||||
// and a radio told nothing keeps the operator's microphone, which is what
|
||||
// every PTT that is not a voice message should do.
|
||||
func (a *App) applyTCITXSource() {
|
||||
if a.cat == nil {
|
||||
return
|
||||
}
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
src := ""
|
||||
if cfg.ToRadio == audio.NetworkDeviceID {
|
||||
src = "tci"
|
||||
}
|
||||
_ = a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
||||
if s, ok := t.(interface{ SetTXAudioSource(string) }); ok {
|
||||
s.SetTXAudioSource(src)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// installTCITXPlayer offers the radio as an audio output, or withdraws it.
|
||||
//
|
||||
// Withdrawing matters as much as offering: a radio that has gone away must stop
|
||||
// being a device the voice keyer will happily "play" to, or a message goes
|
||||
// nowhere and the operator hears their own PTT click and assumes it worked.
|
||||
func (a *App) installTCITXPlayer(on bool) {
|
||||
if !on {
|
||||
audio.SetNetworkPlayer(nil)
|
||||
return
|
||||
}
|
||||
audio.SetNetworkPlayer(a.tciTXPlayer)
|
||||
a.applyTCITXSource()
|
||||
applog.Printf("tci: the radio is available as an audio output — no virtual cable needed for the voice keyer")
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
// The TCI control console — bindings.
|
||||
//
|
||||
// Thin on purpose: the state is a snapshot the radio pushed and the setters are
|
||||
// one command each. Everything interesting is in internal/cat/tci_panel.go.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
// GetTCIPanel returns the console state. Connected=false when the active CAT
|
||||
// backend is not a TCI radio, so the frontend has one thing to look at rather
|
||||
// than an error to distinguish from a disconnected radio.
|
||||
func (a *App) GetTCIPanel() cat.TCIPanelState {
|
||||
if a.cat == nil {
|
||||
return cat.TCIPanelState{}
|
||||
}
|
||||
st, _ := a.cat.TCIPanelState()
|
||||
return st
|
||||
}
|
||||
|
||||
// tciDo is the shape every setter below takes.
|
||||
func (a *App) tciDo(fn func(cat.TCIPanelController) error) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("CAT not initialized")
|
||||
}
|
||||
return a.cat.TCIPanelDo(fn)
|
||||
}
|
||||
|
||||
// SetTCIDrive sets the transmit drive (0-100).
|
||||
func (a *App) SetTCIDrive(v int) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetDrive(v) })
|
||||
}
|
||||
|
||||
// SetTCITuneDrive sets the drive TUNE uses (0-100).
|
||||
func (a *App) SetTCITuneDrive(v int) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetTuneDrive(v) })
|
||||
}
|
||||
|
||||
// SetTCIMicLevel sets the microphone gain (0-100).
|
||||
func (a *App) SetTCIMicLevel(v int) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetMicLevel(v) })
|
||||
}
|
||||
|
||||
// SetTCIVolume sets the receive volume in dB (0 down to -60).
|
||||
func (a *App) SetTCIVolume(db int) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetVolume(db) })
|
||||
}
|
||||
|
||||
// SetTCIMute mutes or unmutes the receiver.
|
||||
func (a *App) SetTCIMute(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetMute(on) })
|
||||
}
|
||||
|
||||
// SetTCIAGC picks the AGC speed: off, long, slow, med, fast.
|
||||
func (a *App) SetTCIAGC(mode string) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetAGC(mode) })
|
||||
}
|
||||
|
||||
// SetTCISquelch turns the squelch on or off.
|
||||
func (a *App) SetTCISquelch(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetSquelch(on) })
|
||||
}
|
||||
|
||||
// SetTCISquelchLevel sets the squelch threshold in dBm.
|
||||
func (a *App) SetTCISquelchLevel(v int) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetSquelchLevel(v) })
|
||||
}
|
||||
|
||||
// SetTCINB, SetTCINR, SetTCIANF and SetTCIAPF switch the receive processing.
|
||||
func (a *App) SetTCINB(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetNB(on) })
|
||||
}
|
||||
func (a *App) SetTCINR(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetNR(on) })
|
||||
}
|
||||
func (a *App) SetTCIANF(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetANF(on) })
|
||||
}
|
||||
func (a *App) SetTCIAPF(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetAPF(on) })
|
||||
}
|
||||
|
||||
// SetTCIFilter sets the passband edges in Hz.
|
||||
func (a *App) SetTCIFilter(lo, hi int) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetFilter(lo, hi) })
|
||||
}
|
||||
|
||||
// SetTCIRIT / SetTCIXIT switch the offsets on; the Offset calls move them.
|
||||
func (a *App) SetTCIRIT(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetRIT(on) })
|
||||
}
|
||||
func (a *App) SetTCIXIT(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetXIT(on) })
|
||||
}
|
||||
func (a *App) SetTCIRITOffset(hz int) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetRITOffset(hz) })
|
||||
}
|
||||
func (a *App) SetTCIXITOffset(hz int) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetXITOffset(hz) })
|
||||
}
|
||||
|
||||
// SetTCILock locks the radio's VFO knob.
|
||||
func (a *App) SetTCILock(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetLock(on) })
|
||||
}
|
||||
|
||||
// SetTCITune starts or stops the tune carrier.
|
||||
//
|
||||
// IT TRANSMITS, and at tune_drive rather than at drive — which is why the panel
|
||||
// shows those two numbers next to the button rather than hiding one of them in
|
||||
// a menu.
|
||||
func (a *App) SetTCITune(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetTune(on) })
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
// Feeding the QSO recorder from the TCI stream.
|
||||
//
|
||||
// The recorder works in 16 kHz mono, which is what its files and its mixing are
|
||||
// built around; TCI delivers 48 kHz stereo float32. The conversion is the whole
|
||||
// of this file, and it happens here rather than in internal/cat because the
|
||||
// radio's job is to hand over what it sent, not to know what the recorder wants.
|
||||
//
|
||||
// Confirmed on a SunSDR (ExpertSDR3 1.5): 2048 samples a frame, 8192 bytes,
|
||||
// four bytes per sample — and a test recording that plays back clean.
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/audio"
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
// tciRecordSink pushes the receive stream into the QSO recorder.
|
||||
//
|
||||
// Installed whenever the TCI backend starts, and harmless when nothing is
|
||||
// recording: PushRX drops what arrives unless a QSO is being captured, so the
|
||||
// cost while idle is a decimation and a function call.
|
||||
func (a *App) tciRecordSink(rate int, samples []float32) {
|
||||
if a.qsoRec == nil || len(samples) == 0 {
|
||||
return
|
||||
}
|
||||
a.qsoRec.PushRX(tciToRecorderPCM(rate, samples))
|
||||
}
|
||||
|
||||
// tciToRecorderPCM converts the stream's mono float samples to the recorder's
|
||||
// 16-bit PCM at its own rate.
|
||||
//
|
||||
// Averaging rather than picking every third sample: dropping samples aliases
|
||||
// everything above 8 kHz back down into the voice band, and on a receiver that
|
||||
// is hiss — the one thing a QSO recording has plenty of. A three-tap mean is a
|
||||
// crude low-pass, but it is a low-pass, and it costs two additions.
|
||||
func tciToRecorderPCM(rate int, samples []float32) []byte {
|
||||
if rate <= 0 {
|
||||
rate = 48000
|
||||
}
|
||||
step := rate / audio.RecorderSampleRate
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
out := make([]byte, 0, (len(samples)/step)*2)
|
||||
for i := 0; i+step <= len(samples); i += step {
|
||||
var sum float32
|
||||
for j := 0; j < step; j++ {
|
||||
sum += samples[i+j]
|
||||
}
|
||||
v := sum / float32(step)
|
||||
if v > 1 {
|
||||
v = 1
|
||||
}
|
||||
if v < -1 {
|
||||
v = -1
|
||||
}
|
||||
var b [2]byte
|
||||
binary.LittleEndian.PutUint16(b[:], uint16(int16(v*32767)))
|
||||
out = append(out, b[0], b[1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// startTCIRecording opens the receive stream and routes it to the recorder.
|
||||
//
|
||||
// Called when the TCI backend comes up, and only when the operator has asked
|
||||
// for it by choosing the radio as their receive device: opening a 384 kB/s
|
||||
// stream on a station that records nothing is work the radio does for nobody.
|
||||
func (a *App) startTCIRecording() {
|
||||
if a.cat == nil {
|
||||
return
|
||||
}
|
||||
// One switch, and it is the one an operator is already looking at: the
|
||||
// "From radio" device. There used to be a tick box here as well, from when
|
||||
// this was an experiment with no device to choose — two controls for one
|
||||
// question, and the second was where nobody would look.
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
if cfg.FromRadio != audio.NetworkDeviceID {
|
||||
return
|
||||
}
|
||||
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
||||
if s, ok := t.(interface {
|
||||
SetTCIAudioSink(func(int, []float32))
|
||||
}); ok {
|
||||
s.SetTCIAudioSink(a.tciRecordSink)
|
||||
}
|
||||
return t.StartTCIAudio(0, 48000)
|
||||
})
|
||||
if err != nil {
|
||||
applog.Printf("tci: could not open the receive stream for recording: %v", err)
|
||||
return
|
||||
}
|
||||
applog.Printf("tci: recording the receive audio over TCI — no virtual cable needed")
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/audio"
|
||||
)
|
||||
|
||||
// The stream is 48 kHz and the recorder works at 16 — three to one. A
|
||||
// recording that keeps every sample plays back three times too fast, which is
|
||||
// the fault that gets blamed on the decoding rather than on the rate.
|
||||
func TestTheStreamIsResampledToTheRecorderRate(t *testing.T) {
|
||||
const in = 48000
|
||||
samples := make([]float32, in/10) // a tenth of a second
|
||||
pcm := tciToRecorderPCM(in, samples)
|
||||
want := (audio.RecorderSampleRate / 10) * 2 // 16-bit
|
||||
if len(pcm) != want {
|
||||
t.Fatalf("a tenth of a second produced %d bytes, want %d", len(pcm), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Full scale must arrive as full scale: a conversion that quietly halves the
|
||||
// level turns a recording into evidence of a fault that is not there.
|
||||
func TestFullScaleSurvivesTheConversion(t *testing.T) {
|
||||
samples := make([]float32, 12)
|
||||
for i := range samples {
|
||||
samples[i] = 1
|
||||
}
|
||||
pcm := tciToRecorderPCM(48000, samples)
|
||||
if len(pcm) < 2 {
|
||||
t.Fatal("no samples came out")
|
||||
}
|
||||
v := int16(binary.LittleEndian.Uint16(pcm[:2]))
|
||||
if v < 32000 {
|
||||
t.Fatalf("full scale came out at %d", v)
|
||||
}
|
||||
}
|
||||
|
||||
// A rate the recorder already works in is passed through rather than mangled by
|
||||
// a division that would round to nothing.
|
||||
func TestAStreamAtTheRecorderRateIsNotDecimated(t *testing.T) {
|
||||
samples := make([]float32, 160)
|
||||
if got, want := len(tciToRecorderPCM(audio.RecorderSampleRate, samples)), 160*2; got != want {
|
||||
t.Fatalf("%d bytes, want %d", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package main
|
||||
|
||||
// Watchlist bindings — the DXHunter watchlist concept as an OpsLog tab.
|
||||
// The store lives in internal/watchlist (global watchlist.json, DXHunter's own
|
||||
// schema); this file is the Wails boundary plus the two places the list meets
|
||||
// the rest of the app: the spot stream (MarkSeen + alert) and the logbook (the
|
||||
// worked-today answer contest entries are judged by).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/qso"
|
||||
"hamlog/internal/watchlist"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// The auto-contest pattern, DXHunter's contest_prefix: while non-empty, any
|
||||
// spotted callsign CONTAINING it is added to the watchlist as a contest entry
|
||||
// by itself — a special-event fleet (HB9WWA, DL0WWA, F4WWA…) is collected as
|
||||
// it appears instead of typed in one by one. Held in an atomic so the spot
|
||||
// pipeline never touches the settings store per spot.
|
||||
func (a *App) GetWatchlistContestPattern() string {
|
||||
if v := a.watchPattern.Load(); v != nil {
|
||||
return v.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetWatchlistContestPattern stores the pattern (global, like the list itself).
|
||||
// Emptying it stops the auto-add; entries already collected stay — deleting an
|
||||
// operator's list because a setting changed is DXHunter behaviour this port
|
||||
// deliberately drops.
|
||||
func (a *App) SetWatchlistContestPattern(p string) {
|
||||
p = strings.ToUpper(strings.TrimSpace(p))
|
||||
a.watchPattern.Store(p)
|
||||
a.setSettingGlobal(keyWatchlistContestPattern, p)
|
||||
}
|
||||
|
||||
// WatchlistEntries returns the list for the tab.
|
||||
func (a *App) WatchlistEntries() []watchlist.Entry {
|
||||
if a.watchlist == nil {
|
||||
return nil
|
||||
}
|
||||
return a.watchlist.Entries()
|
||||
}
|
||||
|
||||
// WatchlistAdd adds a callsign or prefix; contest entries are judged per UTC day.
|
||||
func (a *App) WatchlistAdd(callsign string, contest bool) error {
|
||||
if a.watchlist == nil {
|
||||
return fmt.Errorf("watchlist not initialized")
|
||||
}
|
||||
return a.watchlist.Add(callsign, contest)
|
||||
}
|
||||
|
||||
// WatchlistRemove deletes an entry.
|
||||
func (a *App) WatchlistRemove(callsign string) error {
|
||||
if a.watchlist == nil {
|
||||
return fmt.Errorf("watchlist not initialized")
|
||||
}
|
||||
return a.watchlist.Remove(callsign)
|
||||
}
|
||||
|
||||
// WatchlistSetNotify arms the existing alert path (sound + toast) for an entry.
|
||||
func (a *App) WatchlistSetNotify(callsign string, on bool) error {
|
||||
if a.watchlist == nil {
|
||||
return fmt.Errorf("watchlist not initialized")
|
||||
}
|
||||
return a.watchlist.SetNotify(callsign, on)
|
||||
}
|
||||
|
||||
// WatchlistSetContest flips the per-entry contest rule.
|
||||
func (a *App) WatchlistSetContest(callsign string, on bool) error {
|
||||
if a.watchlist == nil {
|
||||
return fmt.Errorf("watchlist not initialized")
|
||||
}
|
||||
return a.watchlist.SetContest(callsign, on)
|
||||
}
|
||||
|
||||
// WatchlistSlotQuery asks whether one spot's slot is worked — against the whole
|
||||
// log for a normal entry, against TODAY (UTC) for a contest one.
|
||||
type WatchlistSlotQuery struct {
|
||||
Call string `json:"call"`
|
||||
Band string `json:"band"`
|
||||
Mode string `json:"mode"`
|
||||
Contest bool `json:"contest"`
|
||||
}
|
||||
|
||||
// WatchlistWorkedSlots answers a batch of slot questions for the tab.
|
||||
//
|
||||
// Normal entries read the CLUSTER's own slot set — the same cached
|
||||
// WorkedCallSlotKeys index that colours the grid — so the watchlist can never
|
||||
// contradict the cluster about the same spot. That index normalises the mode
|
||||
// exactly as the operator configured (digital grouping on → FT4 counts as FT8's
|
||||
// class; off → exact mode), and the first version of this ignored that: it
|
||||
// asked the alerts' raw-mode index with a CLASS name, matched nothing, and
|
||||
// showed a fully-worked DXpedition as all Needed.
|
||||
//
|
||||
// Contest entries read TODAY's contacts instead — the midnight-UTC reset is
|
||||
// the query's date bound, nothing stored, nothing to reset — normalised through
|
||||
// the same function so the two answers use one grammar.
|
||||
func (a *App) WatchlistWorkedSlots(queries []WatchlistSlotQuery) []bool {
|
||||
out := make([]bool, len(queries))
|
||||
if a.qso == nil || len(queries) == 0 {
|
||||
return out
|
||||
}
|
||||
idx := a.clusterStatusMaps()
|
||||
norm := func(m string) string {
|
||||
m = strings.ToUpper(strings.TrimSpace(m))
|
||||
if idx.normMode != nil {
|
||||
m = idx.normMode(m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
slotKey := func(call, band, mode string) string {
|
||||
up := strings.ToUpper(strings.TrimSpace(call))
|
||||
b := strings.ToLower(strings.TrimSpace(band))
|
||||
if m := norm(mode); m != "" {
|
||||
return up + "|" + b + "|" + m
|
||||
}
|
||||
return up + "|" + b
|
||||
}
|
||||
// A spot whose mode the band plan could only call "DATA" (an F/H DXpedition
|
||||
// off the standard dials, a comment with no mode) cannot be matched exactly:
|
||||
// "DATA" is in nobody's log. Such a spot is judged at digital-CLASS grain —
|
||||
// worked if ANY digital mode of that call is logged on that band. Claiming
|
||||
// NEW MODE because the label differs from FT8 was the reported bug.
|
||||
generic := func(m string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(m)) {
|
||||
case "", "DATA", "DIG", "DIGI", "DIGITAL":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
digKey := func(call, band string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(call)) + "|" + strings.ToLower(strings.TrimSpace(band)) + "|DIG"
|
||||
}
|
||||
needToday := false
|
||||
for _, q := range queries {
|
||||
if q.Contest {
|
||||
needToday = true
|
||||
break
|
||||
}
|
||||
}
|
||||
today := map[string]bool{}
|
||||
if needToday {
|
||||
midnight := time.Now().UTC().Truncate(24 * time.Hour)
|
||||
rows, err := a.qso.SlotsSince(a.ctx, midnight)
|
||||
if err == nil {
|
||||
for _, r := range rows {
|
||||
today[slotKey(r.Callsign, r.Band, r.Mode)] = true
|
||||
today[digKey(r.Callsign, r.Band)] = qso.ModeClass(r.Mode) == "DIG" || today[digKey(r.Callsign, r.Band)]
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, q := range queries {
|
||||
if q.Contest {
|
||||
if generic(q.Mode) {
|
||||
out[i] = today[digKey(q.Call, q.Band)]
|
||||
} else {
|
||||
out[i] = today[slotKey(q.Call, q.Band, q.Mode)]
|
||||
}
|
||||
} else if generic(q.Mode) {
|
||||
if idx.workedCallSlotsDig != nil {
|
||||
_, out[i] = idx.workedCallSlotsDig[digKey(q.Call, q.Band)]
|
||||
}
|
||||
} else if idx.workedCallSlots != nil {
|
||||
_, out[i] = idx.workedCallSlots[slotKey(q.Call, q.Band, q.Mode)]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// watchSpot runs one live spot through the watchlist: last-seen bookkeeping and
|
||||
// the alert, through the SAME event the alert rules fire — the frontend already
|
||||
// knows how to toast and sound it, and a second notification path would be a
|
||||
// second thing to misconfigure.
|
||||
func (a *App) watchSpot(dxCall, band, mode, country, comment string, freqHz int64) {
|
||||
if a.watchlist == nil {
|
||||
return
|
||||
}
|
||||
entry, notify, ok := a.watchlist.MarkSeen(dxCall)
|
||||
if !ok {
|
||||
// Not watched yet — the auto-contest pattern may claim it. Contains, not
|
||||
// prefix: the event string sits anywhere in these calls (HB9WWA, F4WWA/P).
|
||||
if p := a.GetWatchlistContestPattern(); p != "" &&
|
||||
strings.Contains(strings.ToUpper(dxCall), p) {
|
||||
if err := a.watchlist.Add(dxCall, true); err == nil {
|
||||
applog.Printf("watchlist: auto-added %s (contest pattern %q)", dxCall, p)
|
||||
entry, notify, ok = a.watchlist.MarkSeen(dxCall)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "watchlist:changed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ok || !notify || a.ctx == nil {
|
||||
return
|
||||
}
|
||||
// Already worked? Then nothing to announce. Judged HERE, before the sound —
|
||||
// the report was an alert ringing for a slot the tab showed as Worked a
|
||||
// moment later, because the frontend's verdict arrives on a debounce while
|
||||
// the alert used to fire on the raw spot. Same verdict as the tab: exact
|
||||
// slot for named modes, digital class for a generic DATA spot, today-only
|
||||
// for a contest entry.
|
||||
if e, found := a.watchlist.Get(entry); found {
|
||||
if a.WatchlistWorkedSlots([]WatchlistSlotQuery{{Call: dxCall, Band: band, Mode: mode, Contest: e.IsContest}})[0] {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Throttled per entry: a DXpedition lights up every skimmer on the planet,
|
||||
// and forty alerts a minute for one station is a alarm nobody keeps on.
|
||||
a.watchAlertMu.Lock()
|
||||
last := a.watchAlertAt[entry]
|
||||
now := time.Now()
|
||||
if now.Sub(last) < 2*time.Minute {
|
||||
a.watchAlertMu.Unlock()
|
||||
return
|
||||
}
|
||||
a.watchAlertAt[entry] = now
|
||||
a.watchAlertMu.Unlock()
|
||||
wruntime.EventsEmit(a.ctx, "alert:fired", map[string]any{
|
||||
"rule": "Watchlist " + entry,
|
||||
"call": strings.ToUpper(strings.TrimSpace(dxCall)),
|
||||
"band": band,
|
||||
"mode": mode,
|
||||
"freq_hz": freqHz,
|
||||
"country": country,
|
||||
"comment": comment,
|
||||
"sound": true,
|
||||
"visual": true,
|
||||
})
|
||||
}
|
||||
|
||||
// startWatchlistClubLog begins the expedition enrichment: every entry's
|
||||
// ClubLog block (expedition flag, OQRS, LiveStream, QSO totals and 24 h rate)
|
||||
// is refreshed on a rolling schedule — hourly for expeditions, six-hourly for
|
||||
// the rest, DXHunter's own cadence. OpsLog's application API key is used, so
|
||||
// there is nothing to configure.
|
||||
//
|
||||
// Two at a time with a breath between requests: the key is shared by every
|
||||
// OpsLog install, and a hundred-entry watchlist must read as a trickle at
|
||||
// ClubLog's end, not a burst.
|
||||
func (a *App) startWatchlistClubLog() {
|
||||
if a.watchlist == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
// Let the app finish starting before the first network pass.
|
||||
time.Sleep(15 * time.Second)
|
||||
for {
|
||||
stale := a.watchlist.StaleEntries(1*time.Hour, 6*time.Hour)
|
||||
changed := false
|
||||
for _, call := range stale {
|
||||
// The pattern's BASE is what ClubLog knows: VK9* has no log.
|
||||
base := strings.TrimSuffix(call, "*")
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
d, err := watchlist.FetchWatch(ctx, client, clublogAppAPIKey, base)
|
||||
cancel()
|
||||
if err != nil {
|
||||
applog.Printf("watchlist clublog: %s: %v", base, err)
|
||||
continue
|
||||
}
|
||||
if a.watchlist.ApplyClubLog(call, d) {
|
||||
changed = true
|
||||
if d.IsExpedition {
|
||||
applog.Printf("watchlist clublog: %s is a DXpedition (%d QSOs, %d/24h, OQRS=%v)",
|
||||
base, func() int {
|
||||
if d.ClubLogInfo != nil {
|
||||
return d.ClubLogInfo.TotalQSOs
|
||||
}
|
||||
return 0
|
||||
}(), d.QSOs24h(), d.HasOQRS)
|
||||
}
|
||||
}
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
}
|
||||
if changed && a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "watchlist:changed")
|
||||
}
|
||||
time.Sleep(10 * time.Minute)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
// Log-aware colours in WSJT-X / JTDX's own Band Activity window (message 13),
|
||||
// the way JTAlert paints them: a decode of a watchlist member, a new DXCC or a
|
||||
// new band for its entity is highlighted where the operator is actually
|
||||
// looking. The verdicts come from the same cluster status cache that colours
|
||||
// the spot grid, so the two windows can never disagree.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/dxcc"
|
||||
udp "hamlog/internal/integrations/udp"
|
||||
)
|
||||
|
||||
const (
|
||||
keyWsjtHighlight = "udp.wsjt.highlight"
|
||||
keyWsjtFollowMode = "udp.wsjt.followmode" // spot clicks switch the decoder's mode
|
||||
)
|
||||
|
||||
// wsjtModes are the modes a Configure message can meaningfully ask for — the
|
||||
// decoder's own vocabulary. Anything else (CW, SSB, RTTY) is none of its
|
||||
// business and is not sent.
|
||||
var wsjtModes = map[string]bool{
|
||||
"FT8": true, "FT4": true, "JT65": true, "JT9": true,
|
||||
"MSK144": true, "Q65": true, "FST4": true, "JS8": false, // JS8Call speaks another protocol
|
||||
}
|
||||
|
||||
// GetWsjtFollowMode reports whether spot clicks retune the decoder's mode.
|
||||
func (a *App) GetWsjtFollowMode() bool {
|
||||
return a.settingOr(keyWsjtFollowMode, "1") == "1"
|
||||
}
|
||||
|
||||
// SetWsjtFollowMode flips it.
|
||||
func (a *App) SetWsjtFollowMode(on bool) {
|
||||
v := "0"
|
||||
if on {
|
||||
v = "1"
|
||||
}
|
||||
a.setSetting(keyWsjtFollowMode, v)
|
||||
}
|
||||
|
||||
// ConfigureDecoderMode asks the connected decoders to switch mode — called by
|
||||
// the frontend after a spot click has tuned the radio. A no-op for modes the
|
||||
// decoder does not speak, and when the option is off or nothing is connected.
|
||||
func (a *App) ConfigureDecoderMode(mode string) {
|
||||
mode = strings.ToUpper(strings.TrimSpace(mode))
|
||||
if a.udp == nil || !wsjtModes[mode] || !a.GetWsjtFollowMode() {
|
||||
return
|
||||
}
|
||||
a.udp.SendConfigureMode(mode)
|
||||
}
|
||||
|
||||
// The palette. Fixed colours, not theme tokens — they are painted into another
|
||||
// application's window, which has no idea what theme OpsLog wears.
|
||||
var (
|
||||
hlWatchlist = udp.RGB{R: 244, G: 114, B: 182} // the watchlist pink
|
||||
hlNewDXCC = udp.RGB{R: 22, G: 130, B: 60} // green
|
||||
hlNewBand = udp.RGB{R: 226, G: 122, B: 24} // orange
|
||||
hlWhite = udp.RGB{R: 255, G: 255, B: 255}
|
||||
hlBlack = udp.RGB{R: 20, G: 20, B: 20}
|
||||
)
|
||||
|
||||
// GetWsjtHighlight reports whether decode highlighting is on.
|
||||
func (a *App) GetWsjtHighlight() bool {
|
||||
return a.settingOr(keyWsjtHighlight, "0") == "1"
|
||||
}
|
||||
|
||||
// SetWsjtHighlight turns decode highlighting on or off. Turning it OFF also
|
||||
// clears every instruction OpsLog installed in the running applications — a
|
||||
// disabled option that leaves stale colours behind looks broken, not disabled.
|
||||
func (a *App) SetWsjtHighlight(on bool) {
|
||||
v := "0"
|
||||
if on {
|
||||
v = "1"
|
||||
}
|
||||
a.setSetting(keyWsjtHighlight, v)
|
||||
a.wsjtHighlightOn.Store(on)
|
||||
if !on && a.udp != nil {
|
||||
for _, inst := range a.udp.Instances() {
|
||||
_ = a.udp.SendClearHighlights(inst)
|
||||
}
|
||||
a.wsjtHLMu.Lock()
|
||||
a.wsjtHLSent = map[string]string{}
|
||||
a.wsjtHLMu.Unlock()
|
||||
applog.Printf("wsjt highlight: off — cleared in every instance")
|
||||
}
|
||||
}
|
||||
|
||||
// maybeHighlightDecode paints one decoded callsign in the instance that heard
|
||||
// it, when the option is on and the verdict is worth a colour. De-duplicated
|
||||
// per instance+call+verdict: a station CQing all evening is decoded four times
|
||||
// a minute, and the instruction only needs to be said once.
|
||||
func (a *App) maybeHighlightDecode(instance, call, band string) {
|
||||
if !a.wsjtHighlightOn.Load() || a.udp == nil || call == "" || instance == "" {
|
||||
return
|
||||
}
|
||||
bg, fg, verdict := a.decodeHighlightVerdict(call, band)
|
||||
key := instance + "|" + strings.ToUpper(call) + "|" + band
|
||||
a.wsjtHLMu.Lock()
|
||||
if a.wsjtHLSent == nil {
|
||||
a.wsjtHLSent = map[string]string{}
|
||||
}
|
||||
if len(a.wsjtHLSent) > 4000 { // bounded; a long session just re-says a few
|
||||
a.wsjtHLSent = map[string]string{}
|
||||
}
|
||||
prev, had := a.wsjtHLSent[key]
|
||||
if had && prev == verdict {
|
||||
a.wsjtHLMu.Unlock()
|
||||
return
|
||||
}
|
||||
a.wsjtHLSent[key] = verdict
|
||||
a.wsjtHLMu.Unlock()
|
||||
if verdict == "" {
|
||||
// Was highlighted under an earlier verdict and no longer deserves it
|
||||
// (the operator just worked them): clear that one callsign.
|
||||
if had && prev != "" {
|
||||
_ = a.udp.SendHighlight(instance, call, nil, nil, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = a.udp.SendHighlight(instance, call, bg, fg, false)
|
||||
}
|
||||
|
||||
// decodeHighlightVerdict ranks a callsign: watchlist beats new-DXCC beats
|
||||
// new-band; anything else is "no colour". The empty verdict doubles as the
|
||||
// clear signal in maybeHighlightDecode.
|
||||
func (a *App) decodeHighlightVerdict(call, band string) (bg, fg *udp.RGB, verdict string) {
|
||||
if a.watchlist != nil {
|
||||
if _, ok := a.watchlist.Match(call); ok {
|
||||
c := hlWatchlist
|
||||
f := hlBlack
|
||||
return &c, &f, "watchlist"
|
||||
}
|
||||
}
|
||||
c := a.clusterStatusMaps()
|
||||
if a.dxcc != nil {
|
||||
if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil {
|
||||
num := dxcc.EntityDXCC(m.Entity.Name)
|
||||
ent := c.entities[num]
|
||||
if ent == nil {
|
||||
bgc, fgc := hlNewDXCC, hlWhite
|
||||
return &bgc, &fgc, "new-dxcc"
|
||||
}
|
||||
if band != "" {
|
||||
if _, workedBand := ent.Bands[strings.ToLower(band)]; !workedBand {
|
||||
bgc, fgc := hlNewBand, hlBlack
|
||||
return &bgc, &fgc, "new-band"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil, ""
|
||||
}
|
||||
@@ -174,6 +174,11 @@ type MatrixColors struct {
|
||||
EntityWorked string `json:"entity_worked"`
|
||||
NotWorked string `json:"not_worked"`
|
||||
CurrentEntry string `json:"current_entry"`
|
||||
// The dot marking a slot already worked with the callsign in hand. It is a
|
||||
// mark rather than a background, so it needs its own two colours: it is
|
||||
// drawn on top of all five of the above and must stay legible over each.
|
||||
MarkWorked string `json:"mark_worked"`
|
||||
MarkConfirmed string `json:"mark_confirmed"`
|
||||
}
|
||||
|
||||
// normMatrixColors keeps only plain hex values. Anything else becomes "" — i.e.
|
||||
@@ -196,6 +201,8 @@ func normMatrixColors(c MatrixColors) MatrixColors {
|
||||
EntityWorked: clean(c.EntityWorked),
|
||||
NotWorked: clean(c.NotWorked),
|
||||
CurrentEntry: clean(c.CurrentEntry),
|
||||
MarkWorked: clean(c.MarkWorked),
|
||||
MarkConfirmed: clean(c.MarkConfirmed),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
package main
|
||||
|
||||
// The first breadcrumbs, written before anything else can fail.
|
||||
//
|
||||
// OpsLog keeps its log in the data folder, which lives beside the executable —
|
||||
// so every fault that happens BEFORE that folder exists is invisible. Reported
|
||||
// from a Windows 10 machine: "the process appears, no data folder is created,
|
||||
// nothing starts", with no file anywhere to say why. There was nothing to read
|
||||
// because the only place we write to had not been created yet.
|
||||
//
|
||||
// This writes to %LOCALAPPDATA%\OpsLog\startup.log instead: a folder Windows
|
||||
// guarantees is writable for the user, whatever OpsLog itself was installed
|
||||
// into. It records the handful of milestones between the process starting and
|
||||
// the window appearing, and nothing else — it is not a second log, it is the
|
||||
// answer to "it does not start".
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// bootLogPath is the file, or "" when even LOCALAPPDATA is unavailable.
|
||||
func bootLogPath() string {
|
||||
dir := os.Getenv("LOCALAPPDATA")
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
dir = os.TempDir()
|
||||
}
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
dir = filepath.Join(dir, "OpsLog")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(dir, "startup.log")
|
||||
}
|
||||
|
||||
// bootLog appends one line. Never fails loudly: it exists to explain a failure,
|
||||
// so it must not become one.
|
||||
func bootLog(format string, args ...any) {
|
||||
p := bootLogPath()
|
||||
if p == "" {
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
// Trimmed when it gets long. A startup log that grows for two years is a
|
||||
// file nobody opens, and the interesting launch is always the last one.
|
||||
if fi, err := f.Stat(); err == nil && fi.Size() > 256*1024 {
|
||||
f.Close()
|
||||
_ = os.Remove(p)
|
||||
f, err = os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
}
|
||||
fmt.Fprintf(f, "%s %s\n", time.Now().Format("2006-01-02 15:04:05.000"), fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// bootLogLaunch records what was launched and from where.
|
||||
func bootLogLaunch() {
|
||||
exe, _ := os.Executable()
|
||||
bootLog("launch: %s %v", exe, os.Args[1:])
|
||||
}
|
||||
|
||||
// checkDataDirWritable makes sure the folder OpsLog keeps everything in can
|
||||
// actually be created and written to, and says exactly what failed if not.
|
||||
//
|
||||
// The data folder sits beside the executable, which is fine on a stick or in a
|
||||
// home directory and refused outright under Program Files — where Windows
|
||||
// silently denies the write to anything not elevated. That refusal used to end
|
||||
// the launch with no window and no message.
|
||||
func checkDataDirWritable() error {
|
||||
dir, err := userDataDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot work out where to keep the data: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("cannot create the data folder %s: %w", dir, err)
|
||||
}
|
||||
probe := filepath.Join(dir, ".writetest")
|
||||
if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
|
||||
return fmt.Errorf("the data folder %s cannot be written to: %w", dir, err)
|
||||
}
|
||||
_ = os.Remove(probe)
|
||||
bootLog("data dir ok: %s", dir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// startupReached flips as soon as OnStartup runs — the first moment OpsLog's
|
||||
// own code is executing inside the window's lifecycle.
|
||||
var startupReached atomic.Bool
|
||||
|
||||
// startupStuckMessage is what an operator sees when the window never starts.
|
||||
//
|
||||
// It names the two causes and what to do about each, because "OpsLog is not
|
||||
// responding" sends people to reinstall OpsLog, which is the one thing that
|
||||
// cannot help: the part that has not started is not ours.
|
||||
const startupStuckMessage = "OpsLog started but its window never opened.\n\n" +
|
||||
"This is the WebView2 runtime failing to start, and it is almost always one of two things:\n\n" +
|
||||
"• The Microsoft Edge WebView2 Runtime is missing — install it from Microsoft, then start OpsLog again.\n" +
|
||||
"• An antivirus is blocking msedgewebview2.exe — add OpsLog's folder to its exceptions.\n\n" +
|
||||
"The details are in the OpsLog folder under LOCALAPPDATA (startup.log)."
|
||||
|
||||
// webviewDataPath is where WebView2 keeps its profile.
|
||||
//
|
||||
// Named rather than left to the default, which lands in the ROAMING profile: on
|
||||
// a managed account that folder can be redirected to a network share, and a
|
||||
// WebView2 profile on a share that is slow or unreachable does not fail — it
|
||||
// hangs, which is indistinguishable from a runtime that will not start.
|
||||
//
|
||||
// It also gives the folder a name someone can be told to delete: a corrupt
|
||||
// profile is a real cause of this, and "delete this folder and try again" is
|
||||
// only advice if the folder can be pointed at.
|
||||
func webviewDataPath() string {
|
||||
dir := os.Getenv("LOCALAPPDATA")
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return "" // let Wails decide; there is nothing better to offer
|
||||
}
|
||||
p := filepath.Join(dir, "OpsLog", "WebView2")
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
bootLog("WebView2 profile folder %s could not be created: %v — leaving it to Wails", p, err)
|
||||
return ""
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// stuckMarkerPath is written before the window is attempted and removed once it
|
||||
// opens, so the NEXT launch can tell that the last one never got there.
|
||||
func stuckMarkerPath() string {
|
||||
dir := os.Getenv("LOCALAPPDATA")
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
dir = os.TempDir()
|
||||
}
|
||||
return filepath.Join(dir, "OpsLog", ".launching")
|
||||
}
|
||||
|
||||
// lastLaunchHung is set at startup from the marker left by the previous run.
|
||||
var lastLaunchHung bool
|
||||
|
||||
// noteLaunchAttempt records that a window is about to be attempted, and reports
|
||||
// whether the previous attempt ever finished.
|
||||
func noteLaunchAttempt() {
|
||||
p := stuckMarkerPath()
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
lastLaunchHung = true
|
||||
bootLog("the previous launch never opened its window — trying again with GPU acceleration off")
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
_ = os.WriteFile(p, []byte(time.Now().Format(time.RFC3339)), 0o644)
|
||||
}
|
||||
|
||||
// clearLaunchMarker is called once the window is up: the attempt finished.
|
||||
func clearLaunchMarker() { _ = os.Remove(stuckMarkerPath()) }
|
||||
|
||||
// fixedWebView2Path finds a FIXED-VERSION WebView2 runtime shipped beside
|
||||
// OpsLog, or "" when there is none.
|
||||
//
|
||||
// Microsoft publishes the runtime in two forms. Evergreen installs itself into
|
||||
// Windows and updates itself — the normal case, and the one this looks for
|
||||
// first. FIXED VERSION is a folder of files an application carries with it and
|
||||
// points at, needing no installation and no administrator: it exists precisely
|
||||
// for machines where Evergreen cannot be installed, which is not a rare
|
||||
// situation on a managed, offline or otherwise locked-down Windows.
|
||||
//
|
||||
// So: drop the extracted folder next to OpsLog.exe and it is used. Nothing to
|
||||
// configure — a setting for this would be a question asked of the one operator
|
||||
// least able to answer it, on a machine where nothing starts.
|
||||
func fixedWebView2Path() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
base := filepath.Dir(exe)
|
||||
for _, name := range []string{"WebView2", "WebView2Runtime", "webview2"} {
|
||||
dir := filepath.Join(base, name)
|
||||
if p := findWebView2Binary(dir); p != "" {
|
||||
bootLog("using the fixed-version WebView2 runtime in %s", p)
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findWebView2Binary returns dir (or its single versioned subfolder) when it
|
||||
// holds msedgewebview2.exe — the fixed-version download unpacks with the
|
||||
// version as a folder level, and asking someone to flatten it by hand is one
|
||||
// more step to get wrong.
|
||||
func findWebView2Binary(dir string) string {
|
||||
if fileExists(filepath.Join(dir, "msedgewebview2.exe")) {
|
||||
return dir
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
sub := filepath.Join(dir, e.Name())
|
||||
if fileExists(filepath.Join(sub, "msedgewebview2.exe")) {
|
||||
return sub
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// edgeBlockedMessage is shown when an Edge blocker is found in the way.
|
||||
//
|
||||
// It names the tool rather than the symptom: OpsLog draws its whole interface
|
||||
// with the WebView2 engine, which is Edge's, so a machine where Edge is blocked
|
||||
// cannot run it — and no amount of reinstalling OpsLog will change that.
|
||||
const edgeBlockedMessage = "OpsLog cannot start because Edge is blocked on this PC.\n\n" +
|
||||
"A blocker tool (or a policy) is stopping msedgewebview2.exe from running. " +
|
||||
"OpsLog draws its whole interface with that engine, so it cannot open its window.\n\n" +
|
||||
"Unblock Edge — in the tool that blocked it — and start OpsLog again. " +
|
||||
"The details are in the OpsLog folder under LOCALAPPDATA (startup.log)."
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
// How a Kenwood-dialect radio is reached.
|
||||
//
|
||||
// Three transports, and they are genuinely different things rather than three
|
||||
// spellings of one: a COM port, the same CAT bytes carried over TCP by a serial
|
||||
// bridge, and the radio's own network protocol — which is a session with its own
|
||||
// framing and authentication, and is not implemented here.
|
||||
//
|
||||
// Kept as an explicit setting rather than inferred from which field an operator
|
||||
// happened to fill in. The old code preferred a network address whenever one was
|
||||
// present, which is invisible from the settings page: someone who typed a host
|
||||
// months ago, then set a COM port, had a radio that never answered and nothing
|
||||
// on screen to explain it.
|
||||
|
||||
const (
|
||||
kenwoodLinkUSB = "usb" // a COM port
|
||||
kenwoodLinkBridge = "bridge" // RS-232 to Ethernet: the same CAT bytes over TCP
|
||||
kenwoodLinkNative = "native" // the radio's own network protocol — not supported
|
||||
)
|
||||
|
||||
// kenwoodLinkOr resolves the stored choice, falling back to what an older
|
||||
// install can be read as: a configured host meant the bridge, because that is
|
||||
// what the previous code used it for.
|
||||
func kenwoodLinkOr(link, host string) string {
|
||||
switch link {
|
||||
case kenwoodLinkUSB, kenwoodLinkBridge, kenwoodLinkNative:
|
||||
return link
|
||||
}
|
||||
if host != "" {
|
||||
return kenwoodLinkBridge
|
||||
}
|
||||
return kenwoodLinkUSB
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// The transport is a stored choice now, but installs exist that predate it and
|
||||
// hold only a host. Reading those as "bridge" is what keeps a working station
|
||||
// working across the upgrade — the old code used a host for exactly that.
|
||||
func TestTheLinkIsReadFromAnOlderInstall(t *testing.T) {
|
||||
cases := []struct {
|
||||
link, host, want string
|
||||
}{
|
||||
{"", "", kenwoodLinkUSB}, // nothing configured
|
||||
{"", "192.168.1.50:4999", kenwoodLinkBridge}, // upgraded: host only
|
||||
{kenwoodLinkUSB, "192.168.1.50:4999", kenwoodLinkUSB}, // chose USB, host left behind
|
||||
{kenwoodLinkBridge, "", kenwoodLinkBridge},
|
||||
{kenwoodLinkNative, "", kenwoodLinkNative},
|
||||
{"nonsense", "", kenwoodLinkUSB}, // a value nobody wrote: fall back, don't guess
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := kenwoodLinkOr(c.link, c.host); got != c.want {
|
||||
t.Errorf("link=%q host=%q → %q, want %q", c.link, c.host, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The one that matters after the fact: an operator who picks USB while an old
|
||||
// host is still stored must get the COM port. The previous code preferred the
|
||||
// host whenever it was non-empty, which is invisible from the settings page.
|
||||
func TestChoosingUSBBeatsALeftoverHost(t *testing.T) {
|
||||
if got := kenwoodLinkOr(kenwoodLinkUSB, "10.0.0.9:4999"); got != kenwoodLinkUSB {
|
||||
t.Fatalf("a leftover host overrode the operator's choice: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
// What a rigctl client is told when it asks "what frequency is the radio on".
|
||||
//
|
||||
// Hamlib's "f" means the VFO IN USE — where the operator is listening. RigState
|
||||
// follows ADIF, where FreqHz is where we TRANSMIT, and the two are the same
|
||||
// number until split is engaged. They were handed over unchanged, so with split
|
||||
// on every client asking for the dial was given VFO B.
|
||||
//
|
||||
// Reported from a station running an IC-7850 over USB: turning VFO B moved
|
||||
// VFO A. OpsLog was writing nothing to that radio — a client read the dial, was
|
||||
// handed the transmit VFO, and wrote it back as the dial.
|
||||
func TestShareRXFreqAnswersTheListeningVFO(t *testing.T) {
|
||||
const rx, tx = 14195000, 14200500
|
||||
cases := []struct {
|
||||
name string
|
||||
st cat.RigState
|
||||
want int64
|
||||
}{
|
||||
{
|
||||
name: "simplex — the one frequency there is",
|
||||
st: cat.RigState{FreqHz: rx},
|
||||
want: rx,
|
||||
},
|
||||
{
|
||||
name: "split — the RX VFO, not the TX one",
|
||||
st: cat.RigState{Split: true, FreqHz: tx, RxFreqHz: rx},
|
||||
want: rx,
|
||||
},
|
||||
{
|
||||
// A backend that reports split without ever filling RxFreqHz would
|
||||
// otherwise be answered with 0, and a client told the radio is on
|
||||
// 0 Hz does something worse than nothing with it.
|
||||
name: "split claimed but no RX frequency known",
|
||||
st: cat.RigState{Split: true, FreqHz: tx},
|
||||
want: tx,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := shareRXFreq(c.st); got != c.want {
|
||||
t.Errorf("shareRXFreq(%+v) = %d, want %d", c.st, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// And the pair, stated together: the two questions must not return the same
|
||||
// answer while split is on, or the distinction has been lost somewhere.
|
||||
func TestShareSplitReportsTheOtherVFO(t *testing.T) {
|
||||
st := cat.RigState{Split: true, FreqHz: 14200500, RxFreqHz: 14195000}
|
||||
rxAnswer := shareRXFreq(st)
|
||||
txAnswer := st.FreqHz // what Split() hands back for "i" / get_split_freq
|
||||
if rxAnswer == txAnswer {
|
||||
t.Fatalf("get_freq and get_split_freq both answered %d — a client cannot tell the VFOs apart", rxAnswer)
|
||||
}
|
||||
if rxAnswer != st.RxFreqHz || txAnswer != st.FreqHz {
|
||||
t.Errorf("got rx=%d tx=%d, want rx=%d tx=%d", rxAnswer, txAnswer, st.RxFreqHz, st.FreqHz)
|
||||
}
|
||||
}
|
||||
+344
@@ -1,4 +1,348 @@
|
||||
[
|
||||
{
|
||||
"version": "0.27.3",
|
||||
"date": "",
|
||||
"en": [
|
||||
"KPA500: the amplifier no longer switches itself off and commands respond instantly. A command this model does not know (the KPA1500’s ATU poll) was tearing the link down every cycle, and each reconnect toggled the serial control lines — which are the KPA500’s power switch. The lines are now held steady, silence is not treated as a dead link, and the baud is picked from a list.",
|
||||
"FT decodes: within a period, decodes are listed in arrival order — mirroring the decoder’s own window — instead of strongest-first.",
|
||||
"Voice keyer: twelve message slots (F1–F12) instead of six.",
|
||||
"Preferences open smoothly on a busy station: while the dialog is open, cluster spots, FT decodes and CAT snapshots queue quietly instead of repainting the whole window behind it — everything catches up the moment it closes.",
|
||||
"Voice keyer: a delete button per message — removes the recording and clears the label."
|
||||
],
|
||||
"fr": [
|
||||
"KPA500 : l’ampli ne s’éteint plus tout seul et les commandes répondent instantanément. Une commande inconnue de ce modèle (le poll ATU du KPA1500) détruisait le lien à chaque cycle, et chaque reconnexion basculait les lignes de contrôle série — qui sont l’interrupteur du KPA500. Les lignes sont désormais tenues stables, le silence n’est plus traité comme un lien mort, et le baud se choisit dans une liste.",
|
||||
"FT decodes : dans une période, les décodages sont listés dans l’ordre d’arrivée — comme la fenêtre du décodeur — au lieu du plus fort d’abord.",
|
||||
"Manipulateur vocal : douze messages (F1–F12) au lieu de six.",
|
||||
"Les Préférences restent fluides sur une station chargée : dialogue ouvert, les spots cluster, les décodages FT et les instantanés CAT patientent en file au lieu de repeindre toute la fenêtre derrière — tout se rattrape à la fermeture.",
|
||||
"Manipulateur vocal : un bouton supprimer par message — efface l’enregistrement et le libellé."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.2",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Bulk operations work on any size of selection — setting a field, fixing frequencies, deleting, marking uploads and exporting the selection all failed with “too many SQL variables” past a few tens of thousands of QSOs. Statements are now issued in slices.",
|
||||
"Elecraft console: the power meter reads in real watts. The K3’s bargraph is relative to a range that flips at 12 W — calibrated against a real radio’s full table, the PC setting picks the range and the bar converts to watts.",
|
||||
"Watchlist: a visual pass toward DXHunter’s look — pink callsigns, counter pills, quieter cards with a hover, the ⚡ back on the DXpedition badge.",
|
||||
"WSJT-X / JTDX: OpsLog can highlight decodes in the decoder’s own Band Activity window from your log — watchlist members pink, new DXCC green, new band orange (option in Settings → Connections). And a freshly-started decoder is asked to replay its on-screen decodes, so the FT decodes panel starts full.",
|
||||
"WSJT-X / JTDX / MSHV: only a CHANGED DX Call updates the entry — the decoder re-broadcasts the same call endlessly, and it kept overwriting a spot clicked in OpsLog.",
|
||||
"Map: Zoom DX toward a polar entity no longer frames a band of blank white above the top of the world — the camera stays within the map’s ±85°, the path still draws.",
|
||||
"WSJT-X / JTDX / MSHV: clicking a spot in a digital mode the decoder speaks (FT8, FT4, JT65…) switches the decoder’s mode too — option in Settings → Connections, on by default."
|
||||
],
|
||||
"fr": [
|
||||
"Les opérations groupées fonctionnent quelle que soit la taille de la sélection — définir un champ, corriger des fréquences, supprimer, marquer les uploads et exporter la sélection échouaient avec « too many SQL variables » au-delà de quelques dizaines de milliers de QSO. Les requêtes sont désormais émises par tranches.",
|
||||
"Console Elecraft : le wattmètre lit en vrais watts. Le bargraph du K3 est relatif à une gamme qui bascule à 12 W — calibré sur la table complète d’une vraie radio, le réglage PC choisit la gamme et la barre se convertit en watts.",
|
||||
"Watchlist : une passe visuelle vers le look DXHunter — indicatifs roses, compteurs en pastilles, cartes plus feutrées avec survol, le ⚡ de retour sur le badge DXpedition.",
|
||||
"WSJT-X / JTDX : OpsLog peut surligner les décodages dans la fenêtre Band Activity du décodeur selon votre log — watchlist en rose, nouveau DXCC en vert, nouvelle bande en orange (option dans Réglages → Connections). Et un décodeur fraîchement détecté rejoue ses décodages à l’écran, donc le panneau FT decodes démarre plein.",
|
||||
"WSJT-X / JTDX / MSHV : seul un DX Call qui CHANGE met à jour la saisie — le décodeur rediffuse le même call sans fin, et il écrasait un spot cliqué dans OpsLog.",
|
||||
"Carte : Zoom DX vers une entité polaire ne cadre plus une bande blanche au-dessus du haut du monde — la caméra reste dans les ±85° de la carte, le trajet se dessine toujours.",
|
||||
"WSJT-X / JTDX / MSHV : cliquer un spot dans un mode numérique que le décodeur parle (FT8, FT4, JT65…) change aussi le mode du décodeur — option dans Réglages → Connections, activée par défaut."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.1",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Elecraft console: the S-meter is calibrated against a real K3 — S9 and the +dB readings now match the radio’s own display (they read about two S-units low).",
|
||||
"Elecraft console: an SWR spike (the K3’s own blip at the end of an FT8 frame) no longer fades over twelve seconds — the peak shows briefly, then the meter returns straight to the live reading.",
|
||||
"Icom: the IC-7760 joins the model list (CI-V address B2h).",
|
||||
"Icom console: the IC-7760 gets its real attenuator steps — 6/12/18 dB.",
|
||||
"Icom network audio: the RX stream is decoded correctly — mono is requested and the payload offset was confirmed on a real IC-7760, curing the garbled, clicking audio.",
|
||||
"Icom: switching back from a data mode (USB-D1) to plain USB now sticks — if the rig ignores the data-flag command, it is repeated with the modern one-frame form (0x26). Seen on the IC-7760.",
|
||||
"Icom console: a DATA mode button (USB-D, what FT8 wants), the PSK button drives the rigs that have a native PSK mode, and the power meter reads in real watts on the IC-7760’s 250 W scale.",
|
||||
"Icom network audio: the right codec is requested (16-bit mono LPCM), settled by experiment on a real IC-7760.",
|
||||
"CAT settings: reopening the panel now shows the radio that is actually selected — it always showed the first one, with the fields (MY_RIG included) silently editing the wrong entry.",
|
||||
"IC-7760: the power meter is calibrated against the radio (100 W reads 100 W on the 250 W face) and the RF power setting reads in watts, not a percentage.",
|
||||
"Icom network audio: toggling Listening off and on no longer chops the sound — the monitor restarts as network-fed instead of also opening a USB capture.",
|
||||
"QSO recorder: starting a fresh manual take resets the counter for good — it used to flash 0 and jump back to the previous take’s elapsed time.",
|
||||
"MY_RIG follows the radio actually connected: the entry form now asks the active radio first, before the per-band default — an IC-7760 on the air no longer logs as the Flex the band plan names.",
|
||||
"QSO recorder: a manual take on a network Icom records the network RX stream, not the “From radio” sound card (which captured another radio’s DAX on a mixed station).",
|
||||
"Icom network audio: duplicate and late-retransmitted packets no longer reach the recorder — recordings came out longer than the QSO, slowed and stuttering, while the speakers played fine.",
|
||||
"Icom network audio: it now starts with the app — launching OpsLog connected with audio silently off, and the option had to be unticked and re-saved to hear anything.",
|
||||
"Icom network: when the rig goes quiet on CI-V while the session stays up (seen on the IC-7760), the CI-V flow is re-opened on the spot — recovery in seconds instead of the 35-second full reconnect.",
|
||||
"Icom network: after a session dies young, the redial waits 20 s so the rig can purge the old session first — stops the reconnect-die-reconnect spiral seen on the IC-7760, where each fresh session answered for a second and was then strangled by the previous one’s cleanup.",
|
||||
"Icom network audio, phase 5: the radio can now TAKE audio — with RX audio enabled, pick “Radio (network audio)” as the To-radio device and the voice keyer plays straight to the rig over the LAN, no cable, no virtual sound card. First tested on the IC-7760.",
|
||||
"Icom network audio: listening is now a remembered choice — Stop listening keeps the speakers off across reconnects and restarts, while the stream stays open for the QSO recorder and the voice keyer.",
|
||||
"Icom console: a speaker button beside ON/OFF toggles listening to the network RX audio right from the console — no more trip through Settings → Audio; recordings and the voice keyer keep working either way, and the choice is remembered.",
|
||||
"CAT settings: a “Play it through the speakers” checkbox sits under the Icom RX-audio option — the same remembered switch as the console’s speaker button, applied immediately.",
|
||||
"Icom network audio: “Talk to radio” now works over the LAN too — live microphone straight to the rig, PTT included. With RX audio and a headset, OpsLog is a complete remote station: hear, talk, key CW and log over one network link.",
|
||||
"Icom console: the SUB display shows the sub receiver’s frequency at all times (it was blank until split), and engaging split copies the mode to the TX VFO so it matches the main.",
|
||||
"Icom network audio: the stream now opens regardless of the speaker choice — speakers-off (or a failed output device) was silently disabling the whole stream: no audio session, no recordings, no voice keyer, despite the RX-audio option being ticked.",
|
||||
"Icom network: a radio in standby keeps its session and the console’s ON button — the quiet-CI-V recovery was tearing the session down every 15 s, so a radio that was off when OpsLog started could never be powered on.",
|
||||
"Icom console: the MOX button carries your microphone on a network station, and engaging split aligns the TX VFO’s mode with the main.",
|
||||
"Icom console: the spectrum scope is removed. Every model streams its waveform differently — and on the IC-7760 enabling it killed the whole CI-V link, audio included. The radio’s own scope does it better.",
|
||||
"Icom: any leftover waveform stream is switched off at connect — the scope flag lives in the radio and survived sessions, and its flood is what was killing the IC-7760’s CI-V link.",
|
||||
"Icom console: an ATU chip beside SPLIT engages or DISENGAGES the internal tuner — TUNE started a cycle but could never take the tuner back out of line.",
|
||||
"Icom console: the TX meters get needle inertia — power holds its peak instead of flickering to 0 W between syllables, SWR shows the real peak then returns to the live value — and the wattage readout no longer wraps at three digits.",
|
||||
"Icom console: the meters use the same LED bars as the Flex and Elecraft consoles, and the mode badge says USB or LSB instead of an ambiguous SSB.",
|
||||
"Cluster: spots on the standard FT8/FT4 frequencies of every band now read FT8/FT4 when the comment names no mode — 30 m, 60 m, 80 m FT4, 17 m FT4 and 12 m were falling into the generic DATA bucket.",
|
||||
"OmniRig (Yaesu): with split engaged, tuning to a spot moves BOTH VFOs — on an FT-2000 the write landed on the TX VFO only, so the transmitter QSYed and the receiver stayed behind.",
|
||||
"Worked before: a slow lookup can no longer overwrite a newer one — typing a second callsign quickly could leave the previous call’s QSOs displayed under the new call’s name.",
|
||||
"Icom network: OpsLog now sends its own pings every 500 ms on all three streams, as RS-BA1 and wfview do — a client that only ever replied was judged absent by the rig, which stopped serving CI-V data about a minute into every session: the recurring sound/CAT dropouts."
|
||||
],
|
||||
"fr": [
|
||||
"Console Elecraft : le S-mètre est calibré sur un vrai K3 — S9 et les +dB correspondent désormais à l’affichage de la radio (il lisait environ deux points S trop bas).",
|
||||
"Console Elecraft : un pic de ROS (le sursaut du K3 en fin de trame FT8) ne s’estompe plus pendant douze secondes — le pic s’affiche brièvement, puis le ros-mètre revient directement à la valeur réelle.",
|
||||
"Icom : l’IC-7760 rejoint la liste des modèles (adresse CI-V B2h).",
|
||||
"Console Icom : l’IC-7760 reçoit ses vrais crans d’atténuateur — 6/12/18 dB.",
|
||||
"Audio réseau Icom : le flux RX est décodé correctement — le mono est demandé et l’offset du payload a été confirmé sur un vrai IC-7760, ce qui guérit le son inaudible et les clics.",
|
||||
"Icom : revenir d’un mode data (USB-D1) au USB simple tient désormais — si la radio ignore la commande du drapeau data, elle est répétée sous la forme moderne en une trame (0x26). Constaté sur l’IC-7760.",
|
||||
"Console Icom : un bouton de mode DATA (USB-D, celui de FT8), le bouton PSK pilote les radios qui ont un vrai mode PSK, et le wattmètre lit en watts réels sur l’échelle 250 W de l’IC-7760.",
|
||||
"Audio réseau Icom : le bon codec est demandé (LPCM mono 16 bits), déterminé par l’expérience sur un vrai IC-7760.",
|
||||
"Réglages CAT : rouvrir le panneau montre désormais la radio réellement sélectionnée — il montrait toujours la première, et les champs (MY_RIG compris) modifiaient silencieusement la mauvaise entrée.",
|
||||
"IC-7760 : le wattmètre est calibré sur la radio (100 W affiche 100 W sur l’échelle 250 W) et le réglage RF power se lit en watts, plus en pourcentage.",
|
||||
"Audio réseau Icom : couper puis relancer Listening ne hache plus le son — le moniteur redémarre alimenté par le réseau au lieu d’ouvrir en plus une capture USB.",
|
||||
"Enregistreur de QSO : démarrer une nouvelle prise manuelle remet le compteur à zéro pour de bon — il affichait 0 puis resautait au temps de la prise précédente.",
|
||||
"MY_RIG suit la radio réellement connectée : le formulaire interroge d’abord la radio active, avant le défaut par bande — un IC-7760 à l’antenne ne se logue plus comme le Flex prévu par le plan de bande.",
|
||||
"Enregistreur de QSO : une prise manuelle sur un Icom réseau enregistre le flux RX réseau, pas la carte son « From Radio » (qui capturait le DAX d’une autre radio sur une station mixte).",
|
||||
"Audio réseau Icom : les paquets dupliqués ou retransmis en retard n’atteignent plus l’enregistreur — les enregistrements sortaient plus longs que le QSO, ralentis et hachés, alors que les haut-parleurs jouaient bien.",
|
||||
"Audio réseau Icom : il démarre maintenant avec l’application — au lancement, la connexion se faisait audio coupé, et il fallait décocher/recocher l’option pour entendre quelque chose.",
|
||||
"Réseau Icom : quand la radio se tait sur le CI-V alors que la session tient (constaté sur l’IC-7760), le flux CI-V est rouvert immédiatement — récupération en quelques secondes au lieu des 35 secondes de reconnexion complète.",
|
||||
"Réseau Icom : après une session morte jeune, la renumérotation attend 20 s pour que la radio purge d’abord l’ancienne session — stoppe la spirale reconnexion-mort-reconnexion vue sur l’IC-7760, où chaque session neuve répondait une seconde avant d’être étranglée par le nettoyage de la précédente.",
|
||||
"Audio réseau Icom, phase 5 : la radio peut maintenant RECEVOIR de l’audio — avec le RX audio activé, choisissez « Radio (network audio) » comme périphérique To Radio et le voice keyer joue directement vers la radio par le LAN, sans câble ni carte son virtuelle. Premier test sur l’IC-7760.",
|
||||
"Audio réseau Icom : l’écoute est désormais un choix mémorisé — Stop listening garde les enceintes coupées à travers reconnexions et redémarrages, tandis que le flux reste ouvert pour l’enregistreur de QSO et le voice keyer.",
|
||||
"Console Icom : un bouton haut-parleur à côté de ON/OFF bascule l’écoute du RX audio réseau depuis la console — fini l’aller-retour dans Réglages → Audio ; enregistrements et voice keyer continuent de fonctionner, et le choix est mémorisé.",
|
||||
"Réglages CAT : une case « Écouter dans les enceintes » sous l’option RX audio Icom — le même interrupteur mémorisé que le bouton haut-parleur de la console, appliqué immédiatement.",
|
||||
"Audio réseau Icom : « Talk to radio » fonctionne aussi par le LAN — micro en direct vers la radio, PTT compris. Avec le RX audio et un casque, OpsLog devient une station remote complète : écouter, parler, manipuler la CW et loguer sur un seul lien réseau.",
|
||||
"Console Icom : l’affichage SUB montre la fréquence du sub receiver en permanence (il restait vide hors split), et activer le split copie le mode sur le VFO TX pour qu’il suive le main.",
|
||||
"Audio réseau Icom : le flux s’ouvre désormais indépendamment du choix d’écoute — enceintes coupées (ou périphérique de sortie en échec) désactivait silencieusement tout le flux : pas de session audio, ni enregistrements, ni voice keyer, malgré la case RX audio cochée.",
|
||||
"Réseau Icom : une radio en veille garde sa session et le bouton ON de la console — la récupération du CI-V muet détruisait la session toutes les 15 s, donc une radio éteinte au lancement d’OpsLog ne pouvait jamais être allumée.",
|
||||
"Console Icom : le bouton MOX emporte votre micro sur une station réseau, et activer le split aligne le mode du VFO TX sur le main.",
|
||||
"Console Icom : le scope spectral est retiré. Chaque modèle streame sa forme d’onde différemment — et sur l’IC-7760 son activation tuait tout le lien CI-V, audio compris. Le scope de la radio fait ça mieux.",
|
||||
"Icom : tout flux waveform résiduel est coupé à la connexion — le drapeau scope vit dans la radio et survivait aux sessions, et son flot est ce qui tuait le lien CI-V de l’IC-7760.",
|
||||
"Console Icom : une puce ATU à côté de SPLIT engage ou DÉSENGAGE le tuner interne — TUNE lançait un cycle mais ne pouvait jamais remettre le tuner hors ligne.",
|
||||
"Console Icom : les mètres TX gagnent une inertie d’aiguille — la puissance tient sa crête au lieu de retomber à 0 W entre les syllabes, le ROS montre la vraie crête puis revient à la valeur vive — et l’affichage en watts ne passe plus à la ligne à trois chiffres.",
|
||||
"Console Icom : les mètres utilisent les mêmes barres LED que les consoles Flex et Elecraft, et le badge de mode dit USB ou LSB au lieu d’un SSB ambigu.",
|
||||
"Cluster : les spots sur les fréquences standard FT8/FT4 de chaque bande lisent désormais FT8/FT4 quand le commentaire ne nomme pas de mode — 30 m, 60 m, FT4 80 m, FT4 17 m et 12 m tombaient dans le bloc DATA générique.",
|
||||
"OmniRig (Yaesu) : avec le split actif, se rendre sur un spot déplace LES DEUX VFO — sur un FT-2000 l’écriture n’atteignait que le VFO TX, donc l’émetteur QSYait et le récepteur restait derrière.",
|
||||
"Déjà contacté : une recherche lente ne peut plus écraser une plus récente — taper un second indicatif rapidement pouvait laisser les QSO du call précédent affichés sous le nom du nouveau.",
|
||||
"Réseau Icom : OpsLog envoie désormais ses propres pings toutes les 500 ms sur les trois flux, comme RS-BA1 et wfview — un client qui ne faisait que répondre était jugé absent par la radio, qui cessait de servir le CI-V au bout d’une minute : les coupures récurrentes de son/CAT."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.0",
|
||||
"date": "",
|
||||
"en": [
|
||||
"⭐ NEW — Watchlist (Tools): the DXHunter watchlist as an OpsLog tab — callsigns you are hunting (exact, or a family with a trailing * — VK9* catches every VK9), the live cluster spots under each with the NEW badges and a Needed/Worked verdict per slot, an ON AIR badge, per-entry alerts through the normal alert path, and CONTEST entries judged per UTC day (at midnight everything is workable again).Each entry is enriched from ClubLog automatically: DXpedition badge, OQRS, LiveStream link, QSO total and 24-hour rate — nothing to configure. Reads and writes DXHunter’s own watchlist.json format — drop your existing file into the data folder to carry it over.",
|
||||
"TCI (SunSDR): the meter subscription is renewed when the radio announces it is ready — sent only at connect, it could fall inside the initial state dump and be ignored, which left the transmit power and SWR empty. The log now also records the subscription and the first sensor frames, so a report can tell “the radio never sends them” from “they arrived and were dropped”.",
|
||||
"Sending a spot while in split now pre-fills the RX frequency — where the station actually is — instead of the TX frequency, which spotted the pile-up five up from the DX.",
|
||||
"Cluster: NEW SLOT gets its own colour — the sky cyan the panadapter palette already uses for it, clearly apart from POTA green and the yellows. The NEW SLOT and NEW COUNTY filter chips now wear the same colours as the badges they select, and the NEW CALL filter works even when the slot-highlight display option is off.",
|
||||
"Elecraft console: a mode row — CW, USB, LSB, DATA and DATA RTTY. The two DATA buttons set the K3’s submode as well (DATA A for FT8/FT4, FSK D for RTTY): switching to DATA by mode alone kept whatever submode the last session left, which is how a K3 “in DATA” keys FT8 with no audio.",
|
||||
"DX Cluster settings: “I chase POTA” and “I chase SOTA”, on by default. Unticked, the NEW POTA badge, colour and filter disappear — a new-band + new-POTA spot reads NEW BAND alone — and the reference columns stay empty.",
|
||||
"Elecraft console: RIT and XIT use the same control as the Icom and TCI consoles — ± buttons, mouse wheel, typed value, Ctrl+←/→ — and the power slider moves in 1 W steps instead of 5.",
|
||||
"Bulk edit: My DXCC, My CQ zone and My ITU zone join the My-station fields — numbers checked against their real ranges, empty clears.",
|
||||
"Clicking a spot in the Chase new panel now applies the FlexRadio panadapter width/zoom, like every other spot click.",
|
||||
"FlexRadio: the panadapter width/zoom now applies reliably when double-clicking a cluster spot — the zoom is sent after the tune settles, so SmartSDR’s own pan-follow on a band change no longer overrides it."
|
||||
],
|
||||
"fr": [
|
||||
"⭐ NOUVEAU — Watchlist (Outils) : la watchlist de DXHunter en onglet OpsLog — indicatifs chassés (exacts, ou une famille avec une * finale — VK9* attrape tous les VK9), les spots cluster en direct sous chaque entrée avec les badges NOUVEAU et un verdict Manquant/Contacté par créneau, un badge ON AIR, des alertes par entrée via le circuit d'alerte normal, et des entrées CONTEST jugées par jour UTC (à minuit tout redevient à faire).Chaque entrée est enrichie automatiquement depuis ClubLog : badge DXpedition, OQRS, lien LiveStream, total de QSO et rythme sur 24 h — rien à configurer. Lit et écrit le format watchlist.json de DXHunter — déposez votre fichier existant dans le dossier data pour le récupérer.",
|
||||
"TCI (SunSDR) : l'abonnement aux mesures est renouvelé quand la radio annonce qu'elle est prête — envoyé seulement à la connexion, il pouvait tomber pendant l'envoi initial de l'état et être ignoré, laissant la puissance et le ROS vides en émission. Le journal enregistre aussi l'abonnement et les premières trames de mesure, pour distinguer « la radio ne les envoie jamais » de « elles arrivaient et étaient perdues ».",
|
||||
"Envoyer un spot en split préremplit désormais la fréquence RX — là où la station se trouve réellement — au lieu de la fréquence TX, qui spottait le pile-up cinq au-dessus du DX.",
|
||||
"Cluster : NOUVEAU SLOT reçoit sa propre couleur — le cyan ciel que la palette du panadapter lui donne déjà, bien distinct du vert POTA et des jaunes. Les puces de filtre NOUVEAU SLOT et NOUVEAU COMTÉ portent désormais les couleurs des badges qu'elles sélectionnent, et le filtre NOUVEAU CALL fonctionne même quand l'option de surlignage par slot est désactivée.",
|
||||
"Console Elecraft : une rangée de modes — CW, USB, LSB, DATA et DATA RTTY. Les deux boutons DATA règlent aussi le sous-mode du K3 (DATA A pour FT8/FT4, FSK D pour le RTTY) : passer en DATA par le seul mode gardait le sous-mode de la session précédente, et un K3 « en DATA » manipulait le FT8 sans audio.",
|
||||
"Réglages DX Cluster : « Je chasse le POTA » et « Je chasse le SOTA », cochés par défaut. Décochés, le badge, la couleur et le filtre NOUVEAU POTA disparaissent — un spot nouvelle bande + nouveau POTA affiche seulement NOUVELLE BANDE — et les colonnes de références restent vides.",
|
||||
"Console Elecraft : le RIT et le XIT utilisent la même commande que les consoles Icom et TCI — boutons ±, molette, valeur tapée, Ctrl+←/→ — et le curseur de puissance avance par pas de 1 W au lieu de 5.",
|
||||
"Édition groupée : My DXCC, My CQ zone et My ITU zone rejoignent les champs Ma station — valeurs vérifiées contre leurs bornes réelles, vide efface.",
|
||||
"Cliquer un spot dans le panneau Chase new applique désormais la largeur/zoom du panadapter FlexRadio, comme tout autre clic de spot.",
|
||||
"FlexRadio : la largeur/zoom du panadapter s’applique désormais de façon fiable au double-clic sur un spot du cluster — le zoom est envoyé après le tune, pour que le suivi du panadapter de SmartSDR lors d’un changement de bande ne l’écrase plus."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.23",
|
||||
"date": "",
|
||||
"en": [
|
||||
"LoTW: the downloaded confirmations list gains a Station column, so a list spanning several callsigns says which one each confirmation belongs to. Shown only when the report actually carries more than the one station.",
|
||||
"The band maps follow your IARU region (Settings → General): Region 2’s 40 m runs to 7300 kHz with phone from 7125 and its 80 m to 4000, Region 3’s 80 m to 3900 — the band edges and the CW/digital/phone shading all adjust."
|
||||
],
|
||||
"fr": [
|
||||
"LoTW : la liste des confirmations téléchargées gagne une colonne Station, pour savoir à quel indicatif appartient chaque confirmation quand le téléchargement en couvre plusieurs. Affichée seulement si le rapport en contient effectivement.",
|
||||
"Les cartes de bande suivent votre région IARU (Réglages → Général) : le 40 m de la Région 2 va jusqu'à 7300 kHz avec la phonie dès 7125 et son 80 m jusqu'à 4000, le 80 m de la Région 3 jusqu'à 3900 — les limites de bande et les zones CW/numérique/phonie s'ajustent."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.22",
|
||||
"date": "",
|
||||
"en": [
|
||||
"LoTW download: the QSL details (QSL date, grid, state, county) are now optional and off by default — LoTW takes about ten times longer to build that report, twenty minutes against two on the same account, and marking a confirmation needs none of it. Still asked for automatically when adding the QSOs not found in the log.",
|
||||
"Band map: the tooltip now also names a new prefix or a new grid square. They stay off the 22-pixel colour strip, but leaving them out of the text made the two panels look as though they disagreed — the cluster said NEW PFX about a spot the map called Worked, and both were right. The map’s “Worked” also says whose: it is the ENTITY that was worked on that band and mode, not the callsign, which is what made the two readings look contradictory.",
|
||||
"TCI (SunSDR): clicking a cluster spot no longer needs a second click to get the mode right — the sideband was chosen from the frequency the radio had last reported instead of the one just asked for. The log also names the ExpertSDR version now, and says so when it is older than the 1.5 that panorama spots need.",
|
||||
"Two screens: OpsLog no longer walks off the desktop. On a monitor placed left of the primary one, the saved position was being added to that monitor’s own origin at every launch, so the window moved one screen further out each time until it was invisible.",
|
||||
"CW over TCI: a SunSDR can now be keyed through its own macro keyer — pick TCI as the keyer engine (Settings → CW Keyer) and macros, auto-call and the speed control all work over the link already open, with no WinKeyer and no second serial port. NOT TESTED on the air yet.",
|
||||
"LoTW upload: a refusal now shows TQSL’s own explanation — which contacts were already uploaded, which fell outside the certificate’s dates — instead of the bare “no QSOs processed”, and names the two settings that cause it.",
|
||||
"Main tab: the docked cluster now has ONE header row — its title, live count and Filters button sit with Clear filters and Columns, as Recent QSOs beside it already did. The pane is titled DX Cluster.",
|
||||
"A busy cluster no longer makes the rest of the interface sluggish: incoming spots are grouped into fewer, larger updates as the feed gets faster (up to half a second), instead of redrawing the window twenty times a second. A quiet cluster still shows each spot as it lands.",
|
||||
"SunSDR console: the meters work. The S-meter, transmit power and SWR are pushed by the radio only to a client that subscribes, and OpsLog never did — it was reading commands ExpertSDR3 does not send.",
|
||||
"Cluster: the “N new spots” counter no longer jumps to the whole buffer. It was looking for the row it had frozen on, and a station spotted again replaces its row — so the count fell through to “everything is new”.",
|
||||
"E-mail: a refused SMTP login now says what to do about it — Microsoft 365 and outlook.com have switched off password-based SMTP, and an app password does not bring it back.",
|
||||
"TCI panorama spots: the colour was sent as a negative number and ExpertSDR dropped every spot in silence. It now goes out as the unsigned ARGB integer the protocol document uses, and the first few spots are written to the log verbatim.",
|
||||
"Cluster: “Group duplicates” was hiding the same station on OTHER bands and modes — a DXpedition spotted on five bands showed as one line and four slots disappeared. A duplicate is now what it should always have been: the same station on the same band and mode."
|
||||
],
|
||||
"fr": [
|
||||
"Téléchargement LoTW : les détails QSL (date du QSL, locator, état, comté) deviennent optionnels et désactivés par défaut — LoTW met environ dix fois plus longtemps à construire ce rapport, vingt minutes contre deux sur le même compte, et marquer une confirmation n'en a pas besoin. Toujours demandés automatiquement quand on ajoute les QSO absents du log.",
|
||||
"Carte des bandes : l'infobulle indique aussi un nouveau préfixe ou un nouveau locator. Ils restent hors de la bande de couleur de 22 pixels, mais les omettre du texte donnait l'impression que les deux panneaux se contredisaient — le cluster annonçait NOUVEAU PFX pour un spot que la carte disait contacté, et les deux avaient raison. Le « Contacté » de la carte dit aussi de qui il parle : c'est l'ENTITÉ qui a été contactée sur cette bande et ce mode, pas l'indicatif — d'où l'impression de contradiction.",
|
||||
"TCI (SunSDR) : cliquer un spot du cluster ne demande plus un second clic pour obtenir le bon mode — la bande latérale était choisie d'après la fréquence encore annoncée par la radio au lieu de celle qu'on venait de demander. Le journal indique aussi la version d'ExpertSDR, et signale si elle est antérieure à la 1.5 qu'exigent les spots sur le panorama.",
|
||||
"Deux écrans : OpsLog ne s'échappe plus du bureau. Sur un écran placé à gauche de l'écran principal, la position enregistrée était ajoutée à l'origine de cet écran à chaque lancement, si bien que la fenêtre s'éloignait d'un écran à chaque fois jusqu'à devenir invisible.",
|
||||
"CW en TCI : un SunSDR peut désormais être manipulé par son propre keyer à macros — choisissez TCI comme moteur (Réglages → Manipulateur CW) et les macros, l'appel automatique et le réglage de vitesse passent par la liaison déjà ouverte, sans WinKeyer ni second port série. PAS ENCORE TESTÉ sur l'air.",
|
||||
"Envoi LoTW : un refus affiche désormais l'explication de TQSL — quels contacts étaient déjà envoyés, lesquels tombaient hors des dates du certificat — au lieu du seul « no QSOs processed », et nomme les deux réglages qui en sont la cause.",
|
||||
"Onglet Main : le cluster ancré n'a plus qu'UNE ligne d'en-tête — son titre, le compteur live et le bouton Filtres rejoignent Effacer les filtres et Colonnes, comme le faisait déjà la liste des QSO récents à côté. Le panneau s'intitule DX Cluster.",
|
||||
"Un cluster chargé ne ralentit plus le reste de l'interface : les spots entrants sont regroupés en mises à jour moins nombreuses à mesure que le flux s'accélère (jusqu'à une demi-seconde), au lieu de redessiner la fenêtre vingt fois par seconde. Sur un cluster calme, chaque spot s'affiche toujours dès son arrivée.",
|
||||
"Console SunSDR : les mesures fonctionnent. Le S-mètre, la puissance et le ROS ne sont envoyés qu'à un client qui s'abonne, ce qu'OpsLog ne faisait pas — il lisait des commandes qu'ExpertSDR3 n'envoie pas.",
|
||||
"Cluster : le compteur « N nouveaux spots » ne saute plus à la taille du tampon. Il cherchait la ligne sur laquelle il s'était figé, or une station re-spottée remplace sa ligne — le compte basculait donc sur « tout est nouveau ».",
|
||||
"E-mail : un refus d'authentification SMTP explique désormais quoi faire — Microsoft 365 et outlook.com ont désactivé le SMTP par mot de passe, et un mot de passe d'application ne le rétablit pas.",
|
||||
"Spots sur le panorama TCI : la couleur partait en nombre négatif et ExpertSDR écartait chaque spot en silence. Elle est désormais envoyée en entier ARGB non signé, comme dans la documentation du protocole, et les premiers spots sont écrits tels quels dans le journal.",
|
||||
"Cluster : « Grouper les doublons » masquait la même station sur les AUTRES bandes et modes — une expédition spottée sur cinq bandes n'affichait qu'une ligne et quatre créneaux disparaissaient. Un doublon est désormais ce qu'il aurait toujours dû être : la même station sur la même bande et le même mode."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.21",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Distances can be shown in miles (Settings → General): the cluster and Recent QSOs columns, the map path box, the rotator buttons and the band-opening list all follow, and the column headers name the unit.",
|
||||
"LoTW download: the report is now counted in megabytes as it arrives, LoTW’s \"busy\" answer (HTTP 503) is retried twice instead of failing, and a transfer that stops moving for two minutes says so rather than showing \"working\" indefinitely."
|
||||
],
|
||||
"fr": [
|
||||
"Les distances peuvent s'afficher en miles (Réglages → Général) : les colonnes du cluster et des QSO récents, l'encart du tracé sur la carte, les boutons du rotor et la liste des ouvertures suivent, et l'unité est indiquée dans les en-têtes de colonne.",
|
||||
"Téléchargement LoTW : le rapport est compté en mégaoctets au fur et à mesure, la réponse « occupé » de LoTW (HTTP 503) est retentée deux fois au lieu d'échouer, et un transfert qui n'avance plus pendant deux minutes le dit au lieu d'afficher « en cours » indéfiniment."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.20",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Each radio carries its own MY_RIG (Settings → CAT), written on every QSO made with it — ahead of the per-band station in Operating conditions, which says what you planned to use rather than which radio is keying. Left empty, nothing changes.",
|
||||
"Worked-before matrix: a dot in the corner of a cell now means the callsign you are working has already been worked on that slot, whatever colour the entity status gave the cell. Its two colours (worked / confirmed with this callsign) are in Appearance with the other matrix colours.",
|
||||
"Icom spectrum scope: the command shape (with or without the main/sub selector) is now worked out from the radio’s own answers instead of a list of models. A radio that has scope control but no waveform stream over CI-V — the IC-7851 — says so in the panel rather than showing a black rectangle.",
|
||||
"Elecraft KPA: the status-bar chip now shows the amplifier as connected and switches it between OPERATE and STANDBY like the other brands, and an offline KPA no longer calls itself an Acom.",
|
||||
"Cluster: a SOTA column, read from the summit reference the SOTA feeds put in the spot comment. Clicking the spot fills the QSO’s SOTA award reference, as a POTA spot already did. Turn the column on in Columns. Add cluster.sota.org.uk:7300 as a server to receive the summit spots.",
|
||||
"Awards: a \"Slots to confirm\" filter and a running count beside the reference total, so the gap between worked and confirmed band-slots — the Challenge difference — can be seen reference by reference instead of only as two numbers.",
|
||||
"QSL Manager: a QRZ button next to the Paper QSL search, opening the callsign on QRZ.com.",
|
||||
"LoTW: an \"All my callsigns\" option beside the download. The download is scoped to the profile’s own callsign, so a QSO made as a portable or contest call was confirmed at LoTW and never marked here. Confirmations made under a callsign this logbook has never used are skipped, and a suspiciously small report now shows what LoTW actually answered.",
|
||||
"LoTW download: \"All\" really means all — without a date LoTW answered with a handful of recent confirmations, which looked like a successful download of an empty account. A full account also has time to arrive: the two-minute limit that ended in \"context deadline exceeded\" is now twenty."
|
||||
],
|
||||
"fr": [
|
||||
"Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change.",
|
||||
"Matrice des contacts : un point dans le coin d'une case indique que l'indicatif en cours a déjà été contacté sur ce créneau, quelle que soit la couleur donnée par le statut de l'entité. Ses deux couleurs (contacté / confirmé avec cet indicatif) se règlent dans Apparence avec les autres couleurs de la matrice.",
|
||||
"Scope Icom : la forme des commandes (avec ou sans le sélecteur main/sub) est déduite des réponses de la radio au lieu d'une liste de modèles. Une radio qui pilote son scope mais ne l'envoie pas en CI-V — l'IC-7851 — l'indique dans le panneau au lieu d'afficher un rectangle noir.",
|
||||
"Elecraft KPA : la pastille de la barre d'état montre enfin l'amplificateur comme connecté et bascule OPERATE / STANDBY comme les autres marques, et un KPA hors ligne ne s'annonce plus comme un Acom.",
|
||||
"Cluster : une colonne SOTA, lue dans la référence de sommet que les flux SOTA mettent dans le commentaire du spot. Cliquer le spot remplit la référence SOTA du QSO, comme le faisait déjà un spot POTA. Colonne à activer dans Colonnes. Ajoutez le serveur cluster.sota.org.uk:7300 pour recevoir les spots de sommets.",
|
||||
"Awards : un filtre « Slots à confirmer » et un compteur à côté du total de références, pour voir l'écart entre créneaux contactés et confirmés — la différence du Challenge — référence par référence et non plus seulement en deux chiffres.",
|
||||
"Gestionnaire QSL : un bouton QRZ à côté de la recherche QSL papier, qui ouvre l'indicatif sur QRZ.com.",
|
||||
"LoTW : une option « Tous mes indicatifs » à côté du téléchargement. Celui-ci est limité à l'indicatif du profil, si bien qu'un QSO fait sous un indicatif portable ou de contest était confirmé chez LoTW sans jamais être marqué ici. Les confirmations faites sous un indicatif que ce carnet n'a jamais utilisé sont ignorées, et un rapport anormalement petit affiche désormais ce que LoTW a réellement répondu.",
|
||||
"Téléchargement LoTW : « Tout » veut enfin dire tout — sans date, LoTW ne renvoyait qu'une poignée de confirmations récentes, ce qui ressemblait à un téléchargement réussi d'un compte vide. Un compte complet a aussi le temps d'arriver : la limite de deux minutes, qui finissait en « context deadline exceeded », passe à vingt."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.19",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Several radios, and one click to change which one is connected. Settings → CAT holds a list — name them, add one (it starts as a copy of the current one), remove one — and the CAT button in the status bar switches between them. The logbook, the callsign and everything else about the station stay exactly as they are, which is what making a profile per radio could not do."
|
||||
],
|
||||
"fr": [
|
||||
"Plusieurs radios, et un clic pour changer celle qui est connectée. Réglages → CAT contient une liste — les nommer, en ajouter une (elle part d'une copie de la courante), en retirer une — et le bouton CAT de la barre d'état passe de l'une à l'autre. Le carnet, l'indicatif et tout le reste de la station ne bougent pas, ce qu'un profil par radio ne permettait pas."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.18",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Awards: the band matrix shows the bands you have contacts on — no more, and no longer capped at 70cm. It used to list every band the award permits, so DDFM stood there with empty 6m and 70cm columns, and it could not show 23cm at all whatever the award said.",
|
||||
"Elecraft KPA500 and KPA1500 amplifiers, over serial or — on the KPA1500 — over the network. Operate/standby, on/off, power, SWR, temperature, supply voltage and current, the band, and faults named in plain words rather than as a code. They join the SPE, the Acom and the PowerGenius, so linking several amplifiers works on them too. NEW, AND NOT YET TESTED ON A REAL AMPLIFIER: it is written from Elecraft's programming reference, so please report what it does.",
|
||||
"The Elecraft amplifiers follow the radio's band by themselves, on the link already open — they have a band command of their own, so no second serial port is involved. It can be turned off for an amplifier wired straight to the radio.",
|
||||
"The Light and Voyager maps came up stamped 'API KEY REQUIRED' across the middle. Carto now watermarks its key-free tiles, so both views move to Esri, the provider already behind Street and Satellite. Light looks the same; Voyager is now a topographic map and is named Topo — anyone who had it selected keeps it.",
|
||||
"Ultrabeam / SteppIR over USB: the COM port can be picked from the list again. It used a field you could also type into, and the list could not be selected from at all.",
|
||||
"A control console for SunSDR / ExpertSDR3, as a tab and as a docked pane: drive and tune drive, mic gain, TUNE, power and SWR while transmitting, volume, mute, squelch, NB, NR, ANF, APF in CW, AGC, filters chosen per mode, RIT and XIT, the VFO lock, and an S-meter reading real dBm. Levels can be dragged, scrolled, stepped or typed. It follows the radio's own window — TCI announces every change, whoever made it.",
|
||||
"The Elecraft console can be docked as a main-view pane. It always could; the setting simply never listed it.",
|
||||
"Spot alerts name the right mode. A 30 m FT8 spot raised an alert saying SSB while the cluster list beside it said DATA: the alert engine had its own, coarser band plan, which called everything above 10.130 phone. Both now read the same table, and a frequency the plan does not cover is left unnamed rather than guessed at.",
|
||||
"Awards: editing an award now refreshes its statistics as well as its grid. Changing which confirmations count — LoTW only, LoTW plus cards — left the whole matrix showing the previous rule's numbers, with nothing to say they were stale.",
|
||||
"The DXCC statistics show the Challenge, and say what the two totals mean. The Challenge counts confirmed band-slots on ten bands and 60 m is not one of them — which is the whole of the gap against LoTW's figure. The Total column is entities, the last column is band-slots, and both now say so when hovered.",
|
||||
"Deleted DXCC entities survive an import. A QSO carrying an entity the ARRL has since deleted — Malyj Vysotskij, Czechoslovakia, the German DR and the rest — was being re-stamped from cty.dat, which only knows where a callsign is TODAY: a 2004 R1MVI came back as European Russia and the credit was gone, for a place that can never be worked again. The number in the file is now left alone for those entities."
|
||||
],
|
||||
"fr": [
|
||||
"Diplômes : la matrice des bandes affiche les bandes sur lesquelles on a des contacts — pas davantage, et sans s'arrêter au 70cm. Elle listait toutes les bandes autorisées par le diplôme, si bien que le DDFM restait avec des colonnes 6m et 70cm vides, et ne pouvait pas afficher le 23cm quoi que dise le diplôme.",
|
||||
"Amplificateurs Elecraft KPA500 et KPA1500, en série ou — pour le KPA1500 — par le réseau. Operate/standby, marche/arrêt, puissance, ROS, température, tension et courant d'alimentation, la bande, et les défauts nommés en clair plutôt qu'en code. Ils rejoignent le SPE, l'Acom et le PowerGenius, donc le couplage de plusieurs amplificateurs fonctionne aussi avec eux. NOUVEAU, ET PAS ENCORE TESTÉ SUR UN VRAI AMPLIFICATEUR : c'est écrit d'après la documentation de programmation d'Elecraft, donc merci de signaler ce que ça donne.",
|
||||
"Les amplificateurs Elecraft suivent tout seuls la bande de la radio, sur la liaison déjà ouverte — ils ont leur propre commande de bande, donc aucun second port série n'intervient. Désactivable pour un amplificateur câblé directement à la radio.",
|
||||
"Les cartes Light et Voyager s'affichaient barrées d'un « API KEY REQUIRED ». Carto marque désormais ses tuiles sans clé, donc les deux vues passent chez Esri, le fournisseur déjà derrière Street et Satellite. Light garde son allure ; Voyager devient une carte topographique et s'appelle Topo — ceux qui l'avaient choisie la conservent.",
|
||||
"Ultrabeam / SteppIR en USB : le port COM se choisit à nouveau dans la liste. Le champ était de ceux où l'on peut aussi taper, et la liste ne se laissait pas sélectionner.",
|
||||
"Une console de contrôle pour SunSDR / ExpertSDR3, en onglet et en volet ancré : puissance et puissance d'accord, gain micro, ACCORD, puissance et ROS pendant l'émission, volume, muet, squelch, NB, NR, ANF, APF en CW, AGC, filtres proposés selon le mode, RIT et XIT, verrouillage du VFO, et un S-mètre en vrais dBm. Les niveaux se règlent à la souris, à la molette, par pas ou en tapant la valeur. Elle suit la fenêtre de la radio — TCI annonce chaque changement, quel qu'en soit l'auteur.",
|
||||
"La console Elecraft peut être ancrée comme volet de la vue principale. Elle le pouvait depuis toujours ; le réglage ne la proposait tout simplement pas.",
|
||||
"Les alertes de spot nomment le bon mode. Un spot FT8 sur 30 m déclenchait une alerte annonçant SSB alors que la liste du cluster juste à côté affichait DATA : le moteur d'alertes avait son propre plan de bandes, plus grossier, qui appelait « phonie » tout ce qui dépassait 10,130. Les deux lisent désormais la même table, et une fréquence hors plan reste sans mode plutôt que devinée.",
|
||||
"Diplômes : modifier un diplôme actualise désormais ses statistiques et pas seulement sa grille. Changer les confirmations qui comptent — LoTW seul, LoTW plus cartes — laissait toute la matrice afficher les chiffres de la règle précédente, sans rien pour dire qu'ils étaient périmés.",
|
||||
"Les statistiques DXCC affichent le Challenge et disent ce que sont les deux totaux. Le Challenge compte les créneaux bande confirmés sur dix bandes, dont le 60 m ne fait pas partie — c'est là tout l'écart avec le chiffre de LoTW. La colonne Total, ce sont les entités ; la dernière, les créneaux bande ; les deux le disent au survol.",
|
||||
"Les entités DXCC supprimées survivent à un import. Un QSO portant une entité depuis supprimée par l'ARRL — Malyj Vysotskij, la Tchécoslovaquie, la RDA et les autres — était réécrit d'après cty.dat, qui ne sait que où se trouve un indicatif AUJOURD'HUI : un R1MVI de 2004 revenait en Russie d'Europe et le crédit disparaissait, pour un endroit qu'on ne pourra plus jamais contacter. Le numéro du fichier est désormais conservé pour ces entités."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.17",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Awards, RDA district comparison: each disagreement can now be settled on its own row. Click the district you keep — the log's or the database's — and apply. It is written into the contact's CNTY and its award reference, and the settled rows leave the list.",
|
||||
"The band/mode matrix: a confirmed slot now shows as confirmed. A callsign worked and not confirmed was outranking an entity CONFIRMED on the same band and mode, so a slot that needs nothing was painted as if it still did.",
|
||||
"CAT sharing: a program connected to OpsLog's rigctl server is told the frequency it is LISTENING on. With split engaged it was given the transmit VFO instead, so a client that reads the dial and writes it back — WSJT-X and its like — could move VFO A when VFO B was turned."
|
||||
],
|
||||
"fr": [
|
||||
"Diplômes, comparaison des districts RDA : chaque divergence se règle désormais sur sa propre ligne. Cliquer le district qu'on garde — celui du log ou celui de la base — puis appliquer. Il est écrit dans le CNTY du contact et dans sa référence de diplôme, et les lignes réglées quittent la liste.",
|
||||
"Matrice bandes/modes : une case confirmée s'affiche enfin comme confirmée. Un indicatif travaillé et non confirmé l'emportait sur une entité CONFIRMÉE sur la même bande et le même mode, si bien qu'une case qui ne demandait plus rien était peinte comme s'il manquait encore quelque chose.",
|
||||
"Partage CAT : un programme connecté au serveur rigctl d'OpsLog reçoit la fréquence sur laquelle on ÉCOUTE. En split, c'était le VFO d'émission qui lui était donné, si bien qu'un client qui lit le VFO et le réécrit — WSJT-X et consorts — pouvait déplacer le VFO A quand on tournait le VFO B."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.16",
|
||||
"date": "",
|
||||
"en": [
|
||||
"TCI transmit needs no setting up in ExpertSDR3 any more. The protocol lets the audio source be named when keying, so OpsLog does it — the voice keyer works in SSB as well as in the digital modes, nothing has to be set per mode in the radio's own window, and the microphone stays the source for every transmission that is not a voice message."
|
||||
],
|
||||
"fr": [
|
||||
"L'émission TCI ne demande plus aucun réglage dans ExpertSDR3. Le protocole permet de nommer la source audio au moment de passer en émission, et OpsLog le fait — le manipulateur vocal fonctionne en SSB comme en numérique, il n'y a plus rien à régler mode par mode dans la fenêtre de la radio, et le micro reste la source pour toute émission qui n'est pas un message vocal."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.15",
|
||||
"date": "",
|
||||
"en": [
|
||||
"SunSDR / ExpertSDR3: the radio's audio now travels over the TCI link itself. Pick 'Radio (TCI network audio)' as the From Radio or To Radio device and the QSO recorder and the voice keyer work with no virtual cable, no second sound card and nothing to set up in the Windows mixer.",
|
||||
"For transmit, ExpertSDR3's own transmit audio source must be set to TCI rather than the microphone — it is remembered per mode, so setting it in SSB does not set it in DIGU. OpsLog says so within a fifth of a second rather than transmitting silence.",
|
||||
"Awards, RDA district comparison: the list stays where it was put. It was thrown back to the first row every three seconds, which made a long list of contacts to correct impossible to work through.",
|
||||
"Elecraft console: the SWR bar works. The radio answers SW; with three digits in tenths of a ratio — SW023 is 2.3:1 — and OpsLog was reading four, so every answer was discarded and the bar stayed empty."
|
||||
],
|
||||
"fr": [
|
||||
"SunSDR / ExpertSDR3 : l'audio de la radio passe désormais par la liaison TCI elle-même. Choisis « Radio (TCI network audio) » comme périphérique From Radio ou To Radio et l'enregistreur de QSO comme le manipulateur vocal fonctionnent sans câble virtuel, sans seconde carte son et sans rien à régler dans le mixeur Windows.",
|
||||
"Pour l'émission, la source audio d'émission d'ExpertSDR3 doit être réglée sur TCI et non sur le micro — elle est mémorisée par mode, donc la régler en SSB ne la règle pas en DIGU. OpsLog le dit en deux dixièmes de seconde au lieu d'émettre du silence.",
|
||||
"Diplômes, comparaison des districts RDA : la liste reste où on l'a laissée. Elle revenait à la première ligne toutes les trois secondes, ce qui rendait impraticable une longue liste de contacts à corriger.",
|
||||
"Console Elecraft : la barre de ROS fonctionne. La radio répond à SW; par trois chiffres en dixièmes de rapport — SW023 vaut 2,3:1 — et OpsLog en lisait quatre, si bien que chaque réponse était jetée et la barre restait vide."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.14",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Elecraft console: the transmit meters are read whenever the RADIO says it is transmitting, not only when OpsLog keyed it. Keying with the front-panel PTT, a footswitch or the mic button left the panel showing a receiver — and the power and SWR bars are read only while transmitting, so for anyone keying by hand they were never read at all.",
|
||||
"Cluster: the list holds still while it is being read. Scrolled away from the top it stops redrawing and shows how many spots are waiting; scrolling back to the top, or clicking the notice, releases it. On a busy evening a spot lands every second or two and the callsign under the pointer had moved by the time the click arrived.",
|
||||
"Cluster command buttons take 500 characters instead of 120. A DXSpider filter listing wanted prefixes runs past a hundred easily, and the field simply stopped accepting keystrokes — saving the command truncated, with nothing to say so.",
|
||||
"Multi-monitor: the saved window position is now checked against the monitors themselves, not the rectangle that spans them. Monitors rarely fill that rectangle, and a window in one of the leftover gaps passed the old test while being invisible. A position that is genuinely lost is moved onto the nearest screen — keeping the window size — instead of being handed back to Windows, and the screen layout is written to the log at every start.",
|
||||
"PowerGenius XL: the amplifier's real state is read at startup. Its status frame carries no 'operate' field — the state is in 'state' — so on the direct GSCP link the flag was never read at all and OpsLog opened claiming STANDBY on an amp that was in line, with the first press of the button then commanding the state it was already in. IDLE means in line, not keyed.",
|
||||
"Antenna Genius: an option to write the SELECTED antenna into MY_ANTENNA, under the name it carries on the switch, ahead of the band default from Operating conditions. Which of the two ports counts is decided by the antenna jack the radio is transmitting on (ANT1/ANT2 on a Flex), with the jack-to-port wiring set once in Preferences — neither device can report it. When the port cannot be told, the log keeps the band default rather than naming an antenna at random.",
|
||||
"The awards tab beside the entry form (F3) now offers only the awards this station follows, and drops the ones switched off — the same list the Awards tab reads. It offered every award that existed, so a station chasing three of them picked references out of a list of twenty.",
|
||||
"CAT settings ask two questions instead of one: WHICH RADIO, then HOW IT IS CONNECTED — and the second only appears where there is a choice to make. OmniRig leads the list, the brands follow alphabetically, and each offers only what it has: USB for a Yaesu or a Xiegu, USB or an RS-232-to-Ethernet bridge for a Kenwood or an Elecraft, USB or its own network protocol for an Icom, and nothing to choose for a FlexRadio or a SunSDR, which are reached one way each. The old list mixed the two questions — 'Icom (USB)' and 'Icom (network)' were separate entries while Kenwood and Elecraft hid the same choice in a field further down — and the example network address named port 4532, which is Hamlib rigctld and the one OpsLog itself serves under Share CAT.",
|
||||
"An editable list of satellites (Preferences → Lists → Satellites). The satellite-name field on the entry form offers them as a dropdown, alphabetically, and still accepts anything typed. SAT_NAME is compared character for character by the awards and by LoTW — AO-91 and AO91 are two different satellites to everything downstream — so a remembered spelling beats one retyped on every pass.",
|
||||
"Awards, RDA district comparison: the conflict list is taller and scrolls, a callsign in it opens the contact, and the compare and fill-districts buttons say they are working. The list was capped at about six visible rows out of two hundred, in a panel that would not scroll to the rest, and nothing in it could be acted on.",
|
||||
"Preferences no longer redraw with the main window. Being a child of the main view, the whole panel was rebuilt on every cluster spot and every CAT update — several times a second on a busy evening — which looked like a page refreshing constantly and made buttons miss their clicks, the element under the pointer being replaced between the press and the release.",
|
||||
"A launch that fails before the window exists now says why, on screen and in a startup log kept outside the data folder. Every fault before that point was invisible — no window, no folder, no file to read — and the causes are named: a missing or blocked WebView2 runtime, an 'Edge blocker' tool stopping the engine OpsLog draws its interface with, a folder Windows refuses to write to, a copy already running. WebView2 also gets its own profile folder on the local disk, retries without GPU acceleration after a launch that hung, and can use a fixed-version runtime dropped beside OpsLog where the installer cannot run."
|
||||
],
|
||||
"fr": [
|
||||
"Console Elecraft : les mesures d'émission sont lues dès que la RADIO se déclare en émission, et plus seulement quand OpsLog l'a mise en émission. Passer en émission par le PTT de façade, une pédale ou le bouton du micro laissait le panneau croire à une réception — et comme les barres de puissance et de ROS ne sont lues qu'en émission, elles ne l'étaient jamais pour qui manipule à la main.",
|
||||
"Cluster : la liste se fige pendant qu'on la lit. Dès qu'on quitte le haut, elle cesse de se redessiner et indique combien de spots attendent ; revenir en haut, ou cliquer sur l'avis, la relâche. Un soir chargé, un spot tombe toutes les une ou deux secondes et l'indicatif sous le pointeur avait bougé avant que le clic n'arrive.",
|
||||
"Les boutons de commande du cluster acceptent 500 caractères au lieu de 120. Un filtre DXSpider qui énumère des préfixes dépasse la centaine sans peine, et le champ cessait simplement d'accepter les frappes — la commande était enregistrée tronquée, sans un mot.",
|
||||
"Multi-écrans : la position enregistrée est désormais vérifiée contre les écrans eux-mêmes, et non contre le rectangle qui les englobe. Les écrans remplissent rarement ce rectangle, et une fenêtre tombée dans un des trous passait l'ancien test tout en étant invisible. Une position réellement perdue est déplacée sur l'écran le plus proche — en conservant la taille de la fenêtre — au lieu d'être rendue à Windows, et la disposition des écrans est écrite dans le journal à chaque démarrage.",
|
||||
"Power Genius XL : l'état réel de l'amplificateur est lu au démarrage. Sa trame d'état ne contient pas de champ « operate » — l'état est dans « state » — si bien que sur la liaison GSCP directe l'indicateur n'était jamais lu : OpsLog s'ouvrait en annonçant STANDBY sur un ampli en ligne, et le premier appui commandait l'état dans lequel il se trouvait déjà. IDLE veut dire en ligne, pas en émission.",
|
||||
"Antenna Genius : une option pour inscrire l'antenne SÉLECTIONNÉE dans MY_ANTENNA, sous le nom qu'elle porte sur le switch, avant l'antenne par défaut des conditions de trafic. C'est la prise d'antenne sur laquelle la radio émet (ANT1/ANT2 sur un Flex) qui décide du port retenu, le câblage prise→port se règlant une fois dans les préférences — aucun des deux appareils ne peut le dire. Quand le port ne peut pas être déterminé, le journal conserve l'antenne par défaut plutôt que d'en nommer une au hasard.",
|
||||
"L'onglet des diplômes à côté de la saisie (F3) ne propose plus que les diplômes suivis par la station, et écarte ceux qui sont désactivés — la même liste que l'onglet Diplômes. Il proposait tous les diplômes existants : une station qui en chasse trois choisissait ses références dans une liste de vingt.",
|
||||
"Les réglages CAT posent deux questions au lieu d'une : QUELLE RADIO, puis COMMENT ELLE EST RELIÉE — la seconde n'apparaissant que là où il y a un choix. OmniRig ouvre la liste, les marques suivent par ordre alphabétique, et chacune ne propose que ce qu'elle a : USB pour un Yaesu ou un Xiegu, USB ou un pont RS-232 vers Ethernet pour un Kenwood ou un Elecraft, USB ou son protocole réseau propre pour un Icom, et rien à choisir pour un FlexRadio ou un SunSDR, qui n'ont qu'une voie chacun. L'ancienne liste mélangeait les deux questions — « Icom (USB) » et « Icom (réseau) » étaient deux entrées tandis que Kenwood et Elecraft cachaient le même choix dans un champ plus bas — et l'exemple d'adresse réseau citait le port 4532, celui de Hamlib rigctld, que OpsLog propose lui-même sous « Partager le CAT ».",
|
||||
"Une liste de satellites éditable (Préférences → Listes → Satellites). Le champ du nom de satellite dans la saisie les propose en liste déroulante, par ordre alphabétique, et accepte toujours ce qu'on tape. SAT_NAME est comparé caractère par caractère par les diplômes et par LoTW — AO-91 et AO91 sont deux satellites différents pour tout ce qui suit — donc une orthographe mémorisée vaut mieux qu'une ressaisie à chaque passage.",
|
||||
"Diplômes, comparaison des districts RDA : la liste des divergences est plus haute et défile, un indicatif y ouvre le contact, et les boutons comparer et remplir les districts disent qu'ils travaillent. Elle était limitée à six lignes visibles environ sur deux cents, dans un panneau qui ne défilait pas pour montrer le reste, et rien n'y était actionnable.",
|
||||
"Les préférences ne se redessinent plus au rythme de la fenêtre principale. Étant un enfant de la vue principale, tout le panneau était reconstruit à chaque spot du cluster et à chaque mise à jour CAT — plusieurs fois par seconde un soir chargé — ce qui donnait l'impression d'une page qui se rafraîchit sans arrêt et faisait rater les clics, l'élément sous le pointeur étant remplacé entre l'appui et le relâchement.",
|
||||
"Un lancement qui échoue avant l'apparition de la fenêtre dit désormais pourquoi, à l'écran et dans un journal de démarrage tenu hors du dossier data. Toute panne survenant avant ce point était invisible — pas de fenêtre, pas de dossier, aucun fichier à lire — et les causes sont nommées : un runtime WebView2 absent ou bloqué, un « bloqueur Edge » qui empêche le moteur avec lequel OpsLog dessine son interface, un dossier où Windows refuse d'écrire, une copie déjà lancée. WebView2 reçoit aussi son propre dossier de profil sur le disque local, réessaie sans accélération GPU après un lancement bloqué, et peut utiliser un runtime en version fixe déposé à côté d'OpsLog là où l'installateur ne peut pas s'exécuter."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.13",
|
||||
"date": "",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
// fatalBox is Windows-only; elsewhere the terminal carries the message.
|
||||
func fatalBox(title, text string) { println(title + ": " + text) }
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// fatalBox shows a message box and returns.
|
||||
//
|
||||
// A GUI-subsystem program has no console: println goes nowhere, and a launch
|
||||
// that ends before the window exists ends in complete silence. Every silent exit
|
||||
// in main now says something here first — an operator who is told "another
|
||||
// OpsLog is already running" can act on it; one who sees nothing files "it does
|
||||
// not start", which is the report nobody can answer.
|
||||
func fatalBox(title, text string) {
|
||||
user32 := syscall.NewLazyDLL("user32.dll")
|
||||
proc := user32.NewProc("MessageBoxW")
|
||||
t, err1 := syscall.UTF16PtrFromString(text)
|
||||
ti, err2 := syscall.UTF16PtrFromString(title)
|
||||
if err1 != nil || err2 != nil {
|
||||
return
|
||||
}
|
||||
const mbIconError = 0x00000010
|
||||
proc.Call(0, uintptr(unsafe.Pointer(t)), uintptr(unsafe.Pointer(ti)), mbIconError)
|
||||
}
|
||||
+425
-87
@@ -1,7 +1,7 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
GetSolarData,
|
||||
GetQSORate,
|
||||
LoTWUserInfo,
|
||||
OperatingDefaultForBand,
|
||||
OperatingDefaultForBand, ActiveRadioMyRig, ConfigureDecoderMode,
|
||||
LogUDPLoggedADIF,
|
||||
ListCountries,
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
IcomSendCW, YaesuSendCW, YaesuStopCW, SetYaesuKeySpeed, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
||||
KenwoodSendCW, KenwoodStopCW, SetKenwoodKeySpeed,
|
||||
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
|
||||
TCISendCW, TCIStopCW, TCISetKeySpeed,
|
||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||
@@ -56,6 +57,8 @@ import {
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
|
||||
import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App';
|
||||
import { formatDistance } from '@/lib/units';
|
||||
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
|
||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
@@ -66,6 +69,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { APP_VERSION, APP_AUTHOR } from '@/version';
|
||||
import { QSLManagerPanel } from '@/components/QSLManagerModal';
|
||||
import { WatchlistTab } from '@/components/WatchlistTab';
|
||||
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
|
||||
import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
|
||||
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
|
||||
@@ -78,6 +82,7 @@ import { WorldMap, LocatorMap } from '@/components/MainMap';
|
||||
import { FlexPanel } from '@/components/FlexPanel';
|
||||
import { IcomPanel } from '@/components/IcomPanel';
|
||||
import { YaesuPanel } from '@/components/YaesuPanel';
|
||||
import { TCIPanel } from '@/components/TCIPanel';
|
||||
import { ElecraftPanel } from '@/components/ElecraftPanel';
|
||||
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
||||
import { MotorAntennaWidget, type AntStatus } from '@/components/MotorAntennaWidget';
|
||||
@@ -94,7 +99,7 @@ import { ExportFieldsDialog } from '@/components/ExportFieldsDialog';
|
||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||
import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
||||
import { applySpotDisplay, chasePota, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
||||
import { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, GetSpotMax, IsNewUSCounty } from '../wailsjs/go/main/App';
|
||||
import { applyMatrixColors } from '@/lib/matrixColors';
|
||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||
@@ -289,6 +294,94 @@ function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.——
|
||||
// shortCatError condenses a backend error into a few words for the topbar
|
||||
// pill. The full message stays in the tooltip. Recognises the common cases
|
||||
// (OmniRig not installed, not registered) and otherwise truncates.
|
||||
// RadioChip — the CAT status chip, and the radio picker behind it.
|
||||
function RadioChip({ catUp, catState, onOpenSettings }: {
|
||||
catUp: boolean; catState: any; onOpenSettings: () => void;
|
||||
}) {
|
||||
const [radios, setRadios] = useState<any[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Re-read on every open rather than on a timer: the list changes when the
|
||||
// operator edits it in Settings, which is exactly when they are not looking
|
||||
// at this chip.
|
||||
const load = () => { ListRadios().then((r: any) => setRadios(r ?? [])).catch(() => {}); };
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [open]);
|
||||
|
||||
// What the operator called this radio comes first.
|
||||
//
|
||||
// The chip showed what the RADIO calls itself over CAT — "Kenwood (911)" for
|
||||
// a K3, a bare "CAT" for a Flex that reports nothing useful — which is the
|
||||
// answer to a question nobody asked once the rig has a name in the list. The
|
||||
// model identity is still there, in the tooltip.
|
||||
const active = radios.find((r: any) => r.active);
|
||||
const named = (active?.name || '').trim();
|
||||
const label = catUp
|
||||
? (named || catState.rig || 'CAT')
|
||||
: (catState.enabled ? (named || shortCatError(catState.error) || 'CAT off') : (named || 'CAT'));
|
||||
// The menu opens with ONE radio too.
|
||||
//
|
||||
// It was only shown from two, on the reasoning that a single radio has
|
||||
// nothing to switch to — but that hides the feature exactly from the operator
|
||||
// who has not made a second radio yet, and the click lands on the settings
|
||||
// dialog they were not asking for. With one radio the menu shows that radio
|
||||
// and the way to add another.
|
||||
const has = radios.length > 0;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (!has) { onOpenSettings(); return; } load(); setOpen((o) => !o); }}
|
||||
title={catUp
|
||||
? `${catState.rig || catState.backend || 'connected'}${has ? ' — click to switch radio, right-click for the CAT settings' : ''}`
|
||||
: (catState.error || 'CAT')}
|
||||
onContextMenu={(e) => { e.preventDefault(); onOpenSettings(); }}
|
||||
className={cn('inline-flex items-center gap-1.5 h-5 px-2 rounded-full border text-[11px] transition-colors',
|
||||
'border-border hover:bg-muted cursor-pointer')}
|
||||
>
|
||||
<span className={cn('size-2 rounded-full', catUp ? 'bg-success' : 'bg-muted-foreground/40')} />
|
||||
<span className="inline-flex items-center gap-1"><RadioTower className="size-3" />{label}</span>
|
||||
{has && <ChevronUp className="size-3 opacity-60" />}
|
||||
</button>
|
||||
{open && has && (
|
||||
<div className="absolute bottom-full left-0 mb-1 z-50 min-w-44 rounded-md border border-border bg-card shadow-lg py-1">
|
||||
{radios.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
if (r.active) return;
|
||||
SetActiveRadio(r.id).then(load).catch(() => {});
|
||||
}}
|
||||
className={cn('flex w-full items-center gap-2 px-2.5 py-1 text-left text-xs hover:bg-muted',
|
||||
r.active && 'font-semibold text-primary')}
|
||||
>
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', r.active ? 'bg-success' : 'bg-muted-foreground/30')} />
|
||||
<span className="flex-1 truncate">{(r.name || '').trim() || r.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase">{r.backend}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="my-1 border-t border-border/60" />
|
||||
<button type="button" onClick={() => { setOpen(false); onOpenSettings(); }}
|
||||
className="w-full px-2.5 py-1 text-left text-xs text-muted-foreground hover:bg-muted">
|
||||
{radios.length > 1 ? 'Radios…' : 'Add a radio…'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shortCatError(err?: string): string {
|
||||
if (!err) return '';
|
||||
const e = err.toLowerCase();
|
||||
@@ -488,6 +581,11 @@ export default function App() {
|
||||
const { t, lang } = useI18n();
|
||||
// === Lists from settings (fallback for first paint) ===
|
||||
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
|
||||
// The station's satellites, for the entry form's SAT_NAME dropdown. Held HERE
|
||||
// and not inside the panel: loadLists() already runs when Preferences close,
|
||||
// so a list edited there reaches the field without a restart — which is what
|
||||
// a panel reading it once at mount could not do.
|
||||
const [satellites, setSatellites] = useState<string[]>([]);
|
||||
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
|
||||
const modesRef = useRef(modes);
|
||||
useEffect(() => { modesRef.current = modes; }, [modes]);
|
||||
@@ -836,11 +934,16 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (!band) return;
|
||||
let cancelled = false;
|
||||
OperatingDefaultForBand(band).then((d) => {
|
||||
OperatingDefaultForBand(band).then(async (d) => {
|
||||
if (cancelled) return;
|
||||
// The radio actually CONNECTED names itself ahead of the per-band plan:
|
||||
// an IC-7760 on the air must not log as the Flex the band default names.
|
||||
let liveRig = '';
|
||||
try { liveRig = (await ActiveRadioMyRig()) || ''; } catch {}
|
||||
if (cancelled) return;
|
||||
setDetails((cur) => ({
|
||||
...cur,
|
||||
my_rig: d?.station_name || '',
|
||||
my_rig: liveRig || d?.station_name || '',
|
||||
my_antenna: d?.antenna_name || '',
|
||||
tx_pwr: d?.tx_pwr ?? cur.tx_pwr,
|
||||
}));
|
||||
@@ -953,6 +1056,13 @@ export default function App() {
|
||||
if (!active) setError(t('rec.manualFailed'));
|
||||
}).catch((e: any) => setError(String(e?.message ?? e)));
|
||||
};
|
||||
// recTick means "a fresh take" — that is the only case where the clock returns
|
||||
// to zero, as opposed to resuming after a stop. Declared BEFORE the ticking
|
||||
// effect below and deliberately so: effects run in declaration order, and the
|
||||
// other way round the ticker captured the PREVIOUS take's elapsed as its
|
||||
// starting point — the counter showed 0 for one second, then jumped straight
|
||||
// back to the old thirty minutes.
|
||||
useEffect(() => { setRecSeconds(0); recSecondsRef.current = 0; setRecStopped(false); }, [recTick]);
|
||||
useEffect(() => {
|
||||
if (!recording) { setRecSeconds(0); return; }
|
||||
// A stopped take freezes the clock where it is: it must show the length of
|
||||
@@ -963,9 +1073,6 @@ export default function App() {
|
||||
const id = window.setInterval(() => setRecSeconds(from + Math.floor((Date.now() - start) / 1000)), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [recording, recTick, recStopped]);
|
||||
// recTick means "a fresh take" — that is the only case where the clock returns
|
||||
// to zero, as opposed to resuming after a stop.
|
||||
useEffect(() => { setRecSeconds(0); recSecondsRef.current = 0; setRecStopped(false); }, [recTick]);
|
||||
// The callsign the in-progress recording belongs to (uppercased; '' = none).
|
||||
// Lets us restart from zero when the operator edits the call to a different
|
||||
// station mid-recording, instead of continuing the old take.
|
||||
@@ -1394,7 +1501,7 @@ export default function App() {
|
||||
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
||||
// auto-call and <LOGQSO> are shared; only the transport differs.
|
||||
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
|
||||
const cwSource: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : wkEngine === 'yaesu' ? 'yaesu' : wkEngine === 'kenwood' ? 'kenwood' : 'winkeyer';
|
||||
const cwSource: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood' | 'tci' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : wkEngine === 'yaesu' ? 'yaesu' : wkEngine === 'kenwood' ? 'kenwood' : wkEngine === 'tci' ? 'tci' : 'winkeyer';
|
||||
// Setting the CW speed has to reach the keyer that is ACTUALLY sending, and
|
||||
// both the CW panel and the Yaesu console can ask for it. With DTR/RTS line
|
||||
// keying the PC does the timing, so the rig's internal keyer speed changes
|
||||
@@ -1408,6 +1515,7 @@ export default function App() {
|
||||
else if (src === 'flex') FlexSetKeySpeed(w).catch(() => {});
|
||||
else if (src === 'yaesu') SetYaesuKeySpeed(w).catch(() => {});
|
||||
else if (src === 'kenwood') SetKenwoodKeySpeed(w).catch(() => {});
|
||||
else if (src === 'tci') TCISetKeySpeed(w).catch(() => {});
|
||||
else WinkeyerSetSpeed(w).catch(() => {});
|
||||
// The rig's own keyer follows too whenever a Yaesu is on CAT, even when it is
|
||||
// not the sending engine: its front panel and OpsLog then agree.
|
||||
@@ -1424,7 +1532,7 @@ export default function App() {
|
||||
// Seed the current break-in from the rig when the CW panel becomes active in
|
||||
// Icom mode (so the control reflects the radio's real state).
|
||||
useEffect(() => {
|
||||
if (cwSource !== 'icom' || !wkEnabled || !(catState.backend === 'icom' && catState.connected)) return;
|
||||
if (cwSource !== 'icom' || !wkEnabled || !((catState.backend === 'icom' || catState.backend === 'icom-net') && catState.connected)) return;
|
||||
GetIcomState().then((s: any) => { if (s && typeof s.break_in === 'number') setIcomBreakIn(s.break_in); }).catch(() => {});
|
||||
}, [cwSource, wkEnabled, catState.backend, catState.connected]);
|
||||
// Auto-call: repeat the clicked macro (e.g. F1 CQ) every (message + N seconds)
|
||||
@@ -1448,10 +1556,11 @@ export default function App() {
|
||||
// skips the log that hasn't happened yet.
|
||||
const wkSendGenRef = useRef(0);
|
||||
useEffect(() => {
|
||||
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
|
||||
const connected = cwSource === 'icom' ? ((catState.backend === 'icom' || catState.backend === 'icom-net') && catState.connected)
|
||||
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
|
||||
: cwSource === 'yaesu' ? (catState.backend === 'yaesu' && catState.connected)
|
||||
: cwSource === 'kenwood' ? (catState.backend === 'kenwood' && catState.connected)
|
||||
: cwSource === 'tci' ? (catState.backend === 'tci' && catState.connected)
|
||||
: wkStatus.connected;
|
||||
wkActiveRef.current = wkEnabled && connected;
|
||||
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
|
||||
@@ -1585,6 +1694,9 @@ export default function App() {
|
||||
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
|
||||
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
|
||||
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
|
||||
// Watchlist tab — opt-in from Tools, persisted, closable like NET Control.
|
||||
const [watchlistEnabled, setWatchlistEnabled] = useState(() => localStorage.getItem('opslog.watchlistEnabled') === '1');
|
||||
useEffect(() => { localStorage.setItem('opslog.watchlistEnabled', watchlistEnabled ? '1' : '0'); }, [watchlistEnabled]);
|
||||
// Contest tab is hidden until enabled from Tools → Contest mode.
|
||||
const [contestTabEnabled, setContestTabEnabled] = useState(() => localStorage.getItem('opslog.contestTab') === '1');
|
||||
useEffect(() => { localStorage.setItem('opslog.contestTab', contestTabEnabled ? '1' : '0'); }, [contestTabEnabled]);
|
||||
@@ -1634,15 +1746,29 @@ export default function App() {
|
||||
const gen = ++dvkAutoCqGenRef.current;
|
||||
dvkAutoCqSlotRef.current = slot;
|
||||
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
||||
// Traced into the app log deliberately: the loop kept 'not repeating' on a
|
||||
// network rig and every guess at which guard was tripping proved wrong.
|
||||
const trace = (m: string) => UILog(`dvk auto-cq: ${m}`).catch(() => {});
|
||||
trace(`start slot=${slot} gen=${gen}`);
|
||||
let round = 0;
|
||||
while (dvkAutoCqSlotRef.current === slot && gen === dvkAutoCqGenRef.current && dvkActiveRef.current) {
|
||||
if (!isPhoneMode(modeRef.current)) { stopDvkAutoCq(); break; }
|
||||
await DVKPlay(slot).catch(() => {});
|
||||
// An EMPTY mode is not a mode change: over the network the rig's mode
|
||||
// read fails transiently and the CAT push blanks the field for a poll or
|
||||
// two — killing the auto-CQ loop mid-run for nothing. Only a known
|
||||
// non-phone mode stops the loop.
|
||||
if (modeRef.current && !isPhoneMode(modeRef.current)) { trace(`stopped: mode ${modeRef.current} is not phone`); stopDvkAutoCq(); break; }
|
||||
round++;
|
||||
trace(`round ${round}: play`);
|
||||
await DVKPlay(slot).catch((e: any) => trace(`play failed: ${e?.message ?? e}`));
|
||||
await sleep(300); // let playback flip "playing" true
|
||||
trace(`round ${round}: playing=${dvkPlayingRef.current}`);
|
||||
let guard = 0; // then wait for it to finish (cap ~90 s)
|
||||
while (dvkPlayingRef.current && gen === dvkAutoCqGenRef.current && guard < 600) { await sleep(150); guard++; }
|
||||
if (gen !== dvkAutoCqGenRef.current) break;
|
||||
if (gen !== dvkAutoCqGenRef.current) { trace(`stopped mid-round: superseded (gen ${dvkAutoCqGenRef.current})`); break; }
|
||||
trace(`round ${round}: message over after ~${(guard * 150 / 1000).toFixed(1)}s, gap ${dvkAutoCqSecsRef.current}s`);
|
||||
await sleep(Math.max(0, dvkAutoCqSecsRef.current) * 1000); // gap before the next CQ
|
||||
}
|
||||
trace(`exit: slotRef=${dvkAutoCqSlotRef.current} gen=${gen}/${dvkAutoCqGenRef.current} active=${dvkActiveRef.current}`);
|
||||
}
|
||||
const dvkPlay = useCallback((slot: number) => {
|
||||
if (!isPhoneMode(modeRef.current)) { setError(t('dvkp.notPhone')); return; }
|
||||
@@ -1915,7 +2041,7 @@ export default function App() {
|
||||
// so it's loaded async on mount and re-read on profile:changed below.
|
||||
// 'none' is only ever stored for the third and fourth panes: the first two are
|
||||
// the Main view, and a layout with no panes at all is not a layout.
|
||||
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'elecraft' | 'netcontrol' | 'decodes' | 'none';
|
||||
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'elecraft' | 'tci' | 'netcontrol' | 'decodes' | 'watchlist' | 'none';
|
||||
const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
|
||||
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
|
||||
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
|
||||
@@ -1926,7 +2052,7 @@ export default function App() {
|
||||
// quarter-width map is unreadable.
|
||||
const [mainLayout4, setMainLayout4] = useState<'cols' | 'quad'>('quad');
|
||||
const loadMainPanes = useCallback(async () => {
|
||||
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'elecraft' || v === 'netcontrol' || v === 'decodes';
|
||||
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'elecraft' || v === 'tci' || v === 'netcontrol' || v === 'decodes' || v === 'watchlist';
|
||||
const [l, r, p3, p4, lay] = await Promise.all([
|
||||
GetUIPref('mainPaneLeft').catch(() => ''),
|
||||
GetUIPref('mainPaneRight').catch(() => ''),
|
||||
@@ -1969,6 +2095,9 @@ export default function App() {
|
||||
useEffect(() => { spotStatusRef.current = spotStatus; }, [spotStatus]);
|
||||
// Mirror of spots so the log-triggered refresh reads the current list without
|
||||
// a stale closure.
|
||||
// Arrival times of the last second's spots, for the adaptive batching window
|
||||
// in the cluster:spot listener.
|
||||
const spotRateRef = useRef<number[]>([]);
|
||||
const spotsRef = useRef(spots);
|
||||
useEffect(() => { spotsRef.current = spots; }, [spots]);
|
||||
// The decoded stations, for the same reason: the status refresh and the cache
|
||||
@@ -2099,6 +2228,15 @@ export default function App() {
|
||||
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
|
||||
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
// While the Settings dialog is open, the spot/decode flushes and the CAT
|
||||
// snapshot stream are PAUSED (data keeps accumulating in the pending refs).
|
||||
// Every flush re-renders the whole App tree behind the dialog — cluster
|
||||
// grid, decodes panel, thousands of nodes — and with a busy cluster plus
|
||||
// two decoders the pointer visibly stuttered over the preferences.
|
||||
const showSettingsRef = useRef(false);
|
||||
useEffect(() => { showSettingsRef.current = showSettings; }, [showSettings]);
|
||||
const flushSpotsRef = useRef<() => void>(() => {});
|
||||
const flushDecodesRef = useRef<() => void>(() => {});
|
||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
||||
useEffect(() => { if (!showSettings) setRotorCompact(localStorage.getItem('opslog.rotorCompact') === '1'); }, [showSettings]);
|
||||
@@ -2437,6 +2575,10 @@ export default function App() {
|
||||
for (const d of decodes) {
|
||||
const seenKey = `${d.call}|${d.ms ?? d.at}|${d.instance ?? ''}`;
|
||||
if (autoSeenRef.current.has(seenKey)) continue;
|
||||
// A Replay's resent history is display-only: answering a line the far
|
||||
// end already dropped would fail anyway, and doing it at startup — the
|
||||
// moment replays arrive — would be a transmitter firing on old news.
|
||||
if ((d as any).is_new === false) { autoSeenRef.current.add(seenKey); continue; }
|
||||
// Only decodes from the CURRENT period are worth answering: replying to a
|
||||
// slot that has closed asks the far end to match a decode it has dropped.
|
||||
if (now - Date.parse(d.at) > 30_000) { autoSeenRef.current.add(seenKey); continue; }
|
||||
@@ -2534,6 +2676,11 @@ export default function App() {
|
||||
// "the field still shows the previous broadcast" (safe to update) from "the
|
||||
// user has typed a different call" (must not clobber).
|
||||
const lastUdpCallRef = useRef('');
|
||||
// Edge detection for the DECODER'S stream: WSJT-X/JTDX/MSHV re-broadcast the
|
||||
// same DX Call in every Status packet, seconds apart, forever. Applying each
|
||||
// one meant a spot clicked in OpsLog was overwritten moments later by the
|
||||
// decoder restating old news. Only a CHANGE in this stream is an event.
|
||||
const lastWsjtEdgeRef = useRef('');
|
||||
|
||||
// When the entered callsign turns out to be worked-before, jump to the
|
||||
// Worked-before tab so the history is front-and-centre. Only once per call,
|
||||
@@ -3081,6 +3228,7 @@ export default function App() {
|
||||
const l: ListsSettings = await GetListsSettings();
|
||||
setRstLists({ phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] });
|
||||
if (l.bands && l.bands.length) setBands(l.bands);
|
||||
setSatellites([...(((l as any).satellites ?? []) as string[])].filter(Boolean).sort());
|
||||
if (l.modes && l.modes.length) {
|
||||
setModePresets(l.modes);
|
||||
const names = l.modes.map((m) => m.name);
|
||||
@@ -3179,7 +3327,7 @@ export default function App() {
|
||||
function handleSpotSelect(s: any) {
|
||||
if (!s?.dx_call?.trim()) return;
|
||||
onCallsignInput(s.dx_call, { force: true });
|
||||
applySpotPOTA((s as any).pota_ref);
|
||||
applySpotRefs((s as any).pota_ref, (s as any).sota_ref);
|
||||
}
|
||||
|
||||
function handleSpotClick(s: any) {
|
||||
@@ -3197,14 +3345,23 @@ export default function App() {
|
||||
noteManualEdit();
|
||||
}
|
||||
if (s.band) setBand(s.band);
|
||||
if (catState.connected) tuneRigCAT(s.freq_hz, m);
|
||||
// The panadapter width follows the MODE of the spot, when the option is on.
|
||||
// AFTER the tune, deliberately: a view change must never delay the frequency
|
||||
// change the operator is waiting for. A no-op on any backend but a Flex.
|
||||
FlexZoomForSpot(m ?? '', s.freq_hz ?? 0).catch(() => {});
|
||||
// A no-op on any backend but a Flex. Sequenced AFTER the tune settles, not
|
||||
// fired alongside it: on a cross-band jump SmartSDR moves the panadapter
|
||||
// itself to follow the slice, and that follow lands after — and overrides —
|
||||
// a zoom sent in the same instant. (A spot clicked ON the panadapter never
|
||||
// hit this: the radio had already tuned before our zoom arrived.)
|
||||
const zoom = () => FlexZoomForSpot(m ?? '', s.freq_hz ?? 0).catch(() => {});
|
||||
if (catState.connected) {
|
||||
void tuneRigCAT(s.freq_hz, m).then(() => window.setTimeout(zoom, 300));
|
||||
} else zoom();
|
||||
if (m) applyModeFromSpot(m);
|
||||
// And the DECODER follows too: an FT4 spot clicked while WSJT-X sits in
|
||||
// FT8 switches its mode (Configure, message 15). The backend filters —
|
||||
// only modes the decoder speaks, only when the option is on.
|
||||
if (m) ConfigureDecoderMode(m).catch(() => {});
|
||||
onCallsignInput(s.dx_call, { force: true });
|
||||
applySpotPOTA((s as any).pota_ref);
|
||||
applySpotRefs((s as any).pota_ref, (s as any).sota_ref);
|
||||
if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call);
|
||||
}
|
||||
|
||||
@@ -3287,7 +3444,15 @@ export default function App() {
|
||||
// Apply a CAT snapshot to the entry strip (freq/band/mode), unless the user
|
||||
// just typed something (freeze window) or locked a field. Shared by the live
|
||||
// cat:state event and the startup poll below.
|
||||
const lastCatWhileSettingsRef = useRef(0);
|
||||
function applyCatState(s: CATState) {
|
||||
// Behind the Settings dialog nobody reads a frequency four times a second;
|
||||
// each snapshot re-renders the whole App tree under the pointer.
|
||||
if (showSettingsRef.current) {
|
||||
const now = Date.now();
|
||||
if (now - lastCatWhileSettingsRef.current < 2000) return;
|
||||
lastCatWhileSettingsRef.current = now;
|
||||
}
|
||||
setCatState(s);
|
||||
if (!s?.connected) return;
|
||||
// A snapshot arriving during the freeze used to be DROPPED, and that lost the
|
||||
@@ -3432,11 +3597,19 @@ export default function App() {
|
||||
// Commit the staged spots: resolve the status for any slot we don't know yet
|
||||
// FIRST, then insert the rows — so they appear with the right badge already
|
||||
// painted instead of flashing plain text then flipping to a pill.
|
||||
// eslint-disable-next-line prefer-const
|
||||
const flushPendingSpots = async () => {
|
||||
pendingSpotTimer.current = undefined;
|
||||
// Settings open: leave everything queued (bounded) and repaint nothing.
|
||||
if (showSettingsRef.current) {
|
||||
const cap = spotsCapRef.current;
|
||||
if (pendingSpotsRef.current.length > cap) pendingSpotsRef.current = pendingSpotsRef.current.slice(-cap);
|
||||
return;
|
||||
}
|
||||
const batch = pendingSpotsRef.current;
|
||||
pendingSpotsRef.current = [];
|
||||
if (batch.length === 0) return;
|
||||
// (registered below so closing Settings can drain the queue)
|
||||
// Resolve unknown statuses before the rows go in.
|
||||
try {
|
||||
const known = spotStatusRef.current;
|
||||
@@ -3492,14 +3665,28 @@ export default function App() {
|
||||
return next.length > cap ? next.slice(0, cap) : next;
|
||||
});
|
||||
};
|
||||
flushSpotsRef.current = () => { void flushPendingSpots(); };
|
||||
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
|
||||
// Stage the spot; a short timer resolves its status then commits it.
|
||||
pendingSpotsRef.current.push(sp);
|
||||
// 50 ms is enough to coalesce an RBN burst into one status lookup (the
|
||||
// worked-index is in memory, so resolving is near-instant) while staying
|
||||
// imperceptible.
|
||||
// The window WIDENS with the rate of the feed.
|
||||
//
|
||||
// Every flush commits state that the whole window re-renders on, so a
|
||||
// fixed 50 ms means twenty full renders a second under an RBN firehose —
|
||||
// and that is felt everywhere else: a dropdown highlighting its entries a
|
||||
// beat late as the mouse moves down them, which is what was reported.
|
||||
//
|
||||
// A quiet cluster keeps the 50 ms: a handful of spots an hour should
|
||||
// appear the moment they arrive. A busy one is coalesced instead, and half
|
||||
// a second's delay on a line in a list that is already scrolling past is
|
||||
// not something anyone can see.
|
||||
const now = Date.now();
|
||||
spotRateRef.current = spotRateRef.current.filter((t) => now - t < 1000);
|
||||
spotRateRef.current.push(now);
|
||||
const perSec = spotRateRef.current.length;
|
||||
const window_ms = perSec > 20 ? 500 : perSec > 5 ? 200 : 50;
|
||||
if (pendingSpotTimer.current === undefined) {
|
||||
pendingSpotTimer.current = window.setTimeout(flushPendingSpots, 50);
|
||||
pendingSpotTimer.current = window.setTimeout(flushPendingSpots, window_ms);
|
||||
}
|
||||
// Self-spot: someone spotted OUR callsign — show it in the shared header
|
||||
// toast (same place as the other notifications), not a separate banner.
|
||||
@@ -3526,6 +3713,10 @@ export default function App() {
|
||||
// decodes panel and plain worked in the cluster list two seconds later.
|
||||
const flushDecodes = async () => {
|
||||
pendingDecodeTimer.current = undefined;
|
||||
if (showSettingsRef.current) {
|
||||
if (pendingDecodesRef.current.length > 3000) pendingDecodesRef.current = pendingDecodesRef.current.slice(-3000);
|
||||
return;
|
||||
}
|
||||
const batch = pendingDecodesRef.current;
|
||||
pendingDecodesRef.current = [];
|
||||
if (batch.length === 0) return;
|
||||
@@ -3568,6 +3759,7 @@ export default function App() {
|
||||
return next;
|
||||
});
|
||||
};
|
||||
flushDecodesRef.current = () => { void flushDecodes(); };
|
||||
|
||||
const unsubDecode = EventsOn('udp:decode', (d: DecodeRow) => {
|
||||
pendingDecodesRef.current.push(d);
|
||||
@@ -3644,6 +3836,13 @@ export default function App() {
|
||||
// Anything that isn't WSJT-X (N1MM, ADIF, a panadapter/cluster click relayed
|
||||
// over UDP…) is an explicit pick → force it over an existing call.
|
||||
const force = String(p?.service ?? '').toLowerCase() !== 'wsjt';
|
||||
if (!force) {
|
||||
// The decoder's stream: same value as last time = no edge = no update.
|
||||
// Only a changed DX Call is the operator doing something over there.
|
||||
const upper = String(p?.call ?? '').trim().toUpperCase();
|
||||
if (upper && upper === lastWsjtEdgeRef.current) return;
|
||||
lastWsjtEdgeRef.current = upper;
|
||||
}
|
||||
// External app moved to a new station → fresh recording for the new target.
|
||||
if (applyUdpCall(p?.call, force)) restartRecordingForNewTarget(String(p?.call ?? ''));
|
||||
});
|
||||
@@ -3654,6 +3853,9 @@ export default function App() {
|
||||
// Only when something is actually in the entry, so an idle digital app doesn't
|
||||
// wipe a call being typed by hand.
|
||||
const unsubClear = EventsOn('udp:clear_call', () => {
|
||||
// The decoder cleared its DX Call: the next call it announces — even the
|
||||
// same one re-selected — is a fresh edge.
|
||||
lastWsjtEdgeRef.current = '';
|
||||
if (callsignRef.current?.value?.trim() || callsign.trim()) resetEntry();
|
||||
});
|
||||
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
|
||||
@@ -3665,14 +3867,14 @@ export default function App() {
|
||||
restartRecordingForNewTarget(call);
|
||||
// The park, like a click in the band map: the radio reports only a
|
||||
// callsign, so the backend looks it up again before sending the event.
|
||||
applySpotPOTA(String(p?.pota_ref ?? ''));
|
||||
applySpotRefs(String(p?.pota_ref ?? ''), String(p?.sota_ref ?? ''));
|
||||
});
|
||||
// Clicking a spot on the ExpertSDR (TCI) panorama fills the call, like Flex.
|
||||
const unsubTciSpot = EventsOn('tci:spot_clicked', (p: any) => {
|
||||
const call = String(p?.call ?? '');
|
||||
if (!applyUdpCall(call, true)) return;
|
||||
restartRecordingForNewTarget(call);
|
||||
applySpotPOTA(String(p?.pota_ref ?? ''));
|
||||
applySpotRefs(String(p?.pota_ref ?? ''), String(p?.sota_ref ?? ''));
|
||||
});
|
||||
const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
|
||||
const total = Number(p?.total ?? 0);
|
||||
@@ -3871,7 +4073,7 @@ export default function App() {
|
||||
// segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
|
||||
// variables correctly.
|
||||
const parts = rawText.split(/<LOGQSO>/i).map((pt) => resolveCW(pt));
|
||||
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex' || cwSourceRef.current === 'yaesu' || cwSourceRef.current === 'kenwood';
|
||||
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex' || cwSourceRef.current === 'yaesu' || cwSourceRef.current === 'kenwood' || cwSourceRef.current === 'tci';
|
||||
for (let p = 0; p < parts.length; p++) {
|
||||
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
|
||||
const resolved = parts[p];
|
||||
@@ -3883,7 +4085,7 @@ export default function App() {
|
||||
// current WPM, so it scales automatically.
|
||||
const keyed = resolved + ' ';
|
||||
setWkSent(resolved);
|
||||
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : cwSourceRef.current === 'yaesu' ? YaesuSendCW : cwSourceRef.current === 'kenwood' ? KenwoodSendCW : null;
|
||||
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : cwSourceRef.current === 'yaesu' ? YaesuSendCW : cwSourceRef.current === 'kenwood' ? KenwoodSendCW : cwSourceRef.current === 'tci' ? TCISendCW : null;
|
||||
if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
// WAIT for THIS segment's CW to finish before moving on — so a <LOGQSO>
|
||||
@@ -3926,6 +4128,7 @@ export default function App() {
|
||||
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
|
||||
else if (cwSourceRef.current === 'kenwood') KenwoodStopCW().catch(() => {});
|
||||
else if (cwSourceRef.current === 'tci') TCIStopCW().catch(() => {});
|
||||
else WinkeyerStop().catch(() => {});
|
||||
}
|
||||
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
|
||||
@@ -3974,6 +4177,10 @@ export default function App() {
|
||||
// send-on-type: key the typed chars verbatim (no variable substitution).
|
||||
function wkSendRaw(chars: string) {
|
||||
if (cwSourceRef.current === 'flex') { FlexSendCW(chars).catch(() => {}); return; }
|
||||
// TCI keys the character as a macro of its own. There is no un-typing it
|
||||
// afterwards — the radio can stop the message but not shorten it — so the
|
||||
// backspace below leaves the TCI engine alone rather than pretending.
|
||||
if (cwSourceRef.current === 'tci') { TCISendCW(chars).catch(() => {}); return; }
|
||||
WinkeyerSend(chars).catch(() => {});
|
||||
}
|
||||
function wkBackspace() {
|
||||
@@ -4188,6 +4395,45 @@ export default function App() {
|
||||
setLookupResult(null);
|
||||
}
|
||||
|
||||
// The Settings dialog is memoised (see SettingsModal), which only helps while
|
||||
// its props hold still. These three would otherwise be new functions on every
|
||||
// App render — several times a second with a cluster running — and the memo
|
||||
// would compare unequal every time and re-render the whole panel anyway.
|
||||
const openEditRef = useRef<(id: number) => void>(() => {});
|
||||
// Refreshed on every render, read only when Settings opens: the dialog gets a
|
||||
// callback whose identity never changes, and still calls the current one.
|
||||
useEffect(() => { openEditRef.current = (id: number) => { void openEdit(id); }; });
|
||||
// The stable wrapper the dialog actually receives.
|
||||
const openQSOFromSettings = useCallback((id: number) => openEditRef.current(id), []);
|
||||
const closeSettings = useCallback(() => {
|
||||
// Synchronously: the ref effect runs after the next render, and flushing
|
||||
// through a still-true ref would hit the pause gate again.
|
||||
showSettingsRef.current = false;
|
||||
window.setTimeout(() => { flushSpotsRef.current(); flushDecodesRef.current(); }, 50);
|
||||
setShowSettings(false);
|
||||
setSettingsSection(undefined);
|
||||
refreshChaseNew();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const onSettingsSaved = useCallback(() => {
|
||||
loadStation(); loadLists(); loadCATCfg(); reloadWk(); refreshManualRecReady();
|
||||
// Drop the cached spot statuses. They are computed once per call+band+mode
|
||||
// and never expire, so a rule change in Settings — grouping the digital
|
||||
// modes into one slot, above all — left every spot already on screen
|
||||
// showing the answer to the OLD question.
|
||||
setSpotStatus({});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const onSettingsPaneChanged = useCallback((side: 'left' | 'right' | 'p3' | 'p4' | 'layout', v: string) => {
|
||||
// Applied from the CHOSEN value, never from a re-read of the DB: the write
|
||||
// is async and the layout must not lag a click behind.
|
||||
if (side === 'left') setMainPaneLeft(v as MainPaneKind);
|
||||
else if (side === 'right') setMainPaneRight(v as MainPaneKind);
|
||||
else if (side === 'p3') setMainPane3(v as MainPaneKind);
|
||||
else if (side === 'p4') setMainPane4(v as MainPaneKind);
|
||||
else if (side === 'layout') setMainLayout4(v === 'cols' ? 'cols' : 'quad');
|
||||
}, []);
|
||||
|
||||
async function openEdit(id: number) {
|
||||
try { setEditingQSO(await GetQSO(id)); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
@@ -4427,10 +4673,17 @@ export default function App() {
|
||||
return () => { dead = true; };
|
||||
}, [selQso, callsign]);
|
||||
|
||||
const wbTokenRef = useRef(0);
|
||||
async function runWorkedBefore(call: string, dxccHint: number = 0) {
|
||||
// Latest-wins: two lookups race when the operator types a second call
|
||||
// before the first answered, and the SLOW one used to land last — the
|
||||
// grid then showed the previous call's QSOs under the new call's name
|
||||
// (LU5AVM's contact filed under LW8ETV, screenshot in hand).
|
||||
const token = ++wbTokenRef.current;
|
||||
setWbBusy(true);
|
||||
try {
|
||||
const w = await WorkedBefore(call, dxccHint);
|
||||
if (token !== wbTokenRef.current) return; // a newer lookup owns the panel
|
||||
setWb(w);
|
||||
// Mirrored synchronously rather than through the effect above: a backfill
|
||||
// parked by the lookup has to read this on the very next line, not a
|
||||
@@ -4445,10 +4698,11 @@ export default function App() {
|
||||
fillFromLastQso(p.r, call);
|
||||
}
|
||||
} catch {
|
||||
if (token !== wbTokenRef.current) return;
|
||||
setWb(null);
|
||||
wbRef.current = null;
|
||||
wbCallRef.current = '';
|
||||
} finally { setWbBusy(false); }
|
||||
} finally { if (token === wbTokenRef.current) setWbBusy(false); }
|
||||
}
|
||||
// fillFromLastQso enriches the entry from the LAST QSO we logged with this call
|
||||
// when the live lookup came up short — the callsign isn't on QRZ/HamQTH, or no
|
||||
@@ -4665,13 +4919,17 @@ export default function App() {
|
||||
}
|
||||
wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150);
|
||||
}
|
||||
// applySpotPOTA sets the QSO's POTA award reference(s) from a clicked spot's
|
||||
// park ref ("US-4164" or n-fer "US-1,US-2"). Empty ref clears it (fresh
|
||||
// target). Routed to the pota_ref column at save via applyAwardRefs.
|
||||
function applySpotPOTA(potaRef?: string) {
|
||||
const refs = String(potaRef || '')
|
||||
.split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean);
|
||||
setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') }));
|
||||
// Award references carried by the spot itself: the park a station is
|
||||
// activating, the summit it is on, or both. Written into award_refs the same
|
||||
// way for each, so logging the contact credits it without retyping a
|
||||
// reference that was on screen.
|
||||
function applySpotRefs(potaRef?: string, sotaRef?: string) {
|
||||
const split = (v?: string) => String(v || '').split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean);
|
||||
const refs = [
|
||||
...split(potaRef).map((r) => `POTA@${r}`),
|
||||
...split(sotaRef).map((r) => `SOTA@${r}`),
|
||||
];
|
||||
setDetails((d) => ({ ...d, award_refs: refs.join(';') }));
|
||||
}
|
||||
function onCallsignInput(v: string, opts?: { force?: boolean }) {
|
||||
// Programmatic call-sets (force: spot click, UDP, external app) count as
|
||||
@@ -4844,6 +5102,7 @@ export default function App() {
|
||||
{ type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
|
||||
{ type: 'item', label: (watchlistEnabled ? '✓ ' : '') + t('tools.watchlist'), action: 'tools.watchlist' },
|
||||
{ type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
|
||||
{ type: 'item', label: t('tools.alerts'), action: 'tools.alerts' },
|
||||
{ type: 'separator' },
|
||||
@@ -4867,7 +5126,7 @@ export default function App() {
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
||||
]},
|
||||
], [total, selectedId, selectedIds, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
|
||||
], [total, selectedId, selectedIds, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, watchlistEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
|
||||
|
||||
function handleMenu(action: string) {
|
||||
switch (action) {
|
||||
@@ -4895,6 +5154,7 @@ export default function App() {
|
||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||
case 'tools.cwdecoder': toggleCwDecoder(); break;
|
||||
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
|
||||
case 'tools.watchlist': setWatchlistEnabled((v) => { const nv = !v; if (nv) setActiveTab('watchlist'); return nv; }); break;
|
||||
case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
|
||||
case 'tools.alerts': setAlertsOpen(true); break;
|
||||
case 'tools.duplicates': setShowDuplicates(true); break;
|
||||
@@ -4978,7 +5238,7 @@ export default function App() {
|
||||
if (dvkActiveRef.current) {
|
||||
// Voice keyer: plain F1..F6 transmit the message; Ctrl+F1..F5 → tabs.
|
||||
if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
|
||||
if (plain && n <= 6) { e.preventDefault(); dvkPlayRef.current(n); return; }
|
||||
if (plain && n <= 12) { e.preventDefault(); dvkPlayRef.current(n); return; }
|
||||
return;
|
||||
}
|
||||
// No keyer: plain F1..F5 switch the detail tab (labels read "F1…").
|
||||
@@ -5710,6 +5970,13 @@ export default function App() {
|
||||
// worked call still matches its own entity status too (new-band/new-slot),
|
||||
// so it stays visible under those chips.
|
||||
const matches = (st !== 'worked' && clusterStatusFilter.has(st))
|
||||
// NEW CALL is a FACT about the callsign (never worked on this band and
|
||||
// mode), surfaced as a status only by the slot-highlight option. The
|
||||
// chip filters on the fact itself, so it works whether or not that
|
||||
// display option is on — worked_slot is computed whenever either
|
||||
// slot option is enabled.
|
||||
|| (clusterStatusFilter.has('new-call') && e?.worked_slot === false
|
||||
&& (!e?.status || e?.status === 'worked' || e?.status === 'new-call'))
|
||||
|| (!!e?.worked_call && clusterStatusFilter.has('worked'))
|
||||
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|
||||
|| (!!e?.new_county && clusterStatusFilter.has('new-county'))
|
||||
@@ -5766,11 +6033,17 @@ export default function App() {
|
||||
});
|
||||
let rendered = list as (ClusterSpot & { repeats?: number })[];
|
||||
if (clusterGroup) {
|
||||
// A DUPLICATE is the same station on the same band AND mode — the dozen
|
||||
// skimmers that all heard one CQ. The same station on another band is the
|
||||
// opposite of a duplicate: it is the line a DX chaser is scanning for, and
|
||||
// grouping on the callsign alone deleted it. RI1FJL spotted on five bands
|
||||
// showed as one row, so four slots simply vanished from the list.
|
||||
const seen = new Map<string, ClusterSpot & { repeats: number }>();
|
||||
for (const s of list) {
|
||||
const e = seen.get(s.dx_call);
|
||||
const key = `${(s.dx_call ?? '').toUpperCase()}|${(s.band ?? '').toLowerCase()}|${inferSpotMode(s.comment ?? '', s.freq_hz)}`;
|
||||
const e = seen.get(key);
|
||||
if (e) { e.repeats++; }
|
||||
else seen.set(s.dx_call, { ...s, repeats: 1 });
|
||||
else seen.set(key, { ...s, repeats: 1 });
|
||||
}
|
||||
rendered = Array.from(seen.values());
|
||||
}
|
||||
@@ -5810,12 +6083,20 @@ export default function App() {
|
||||
);
|
||||
|
||||
const F_CHIP = 'px-1.5 py-[3px] rounded-md border text-[10px] font-bold tracking-wider transition-opacity';
|
||||
const fChip = (key: string, label: string, cls: string, on: boolean, toggle: () => void) => (
|
||||
<button key={key} type="button" onClick={toggle}
|
||||
className={cn(F_CHIP, on ? cls : `${cls} opacity-40 hover:opacity-80`)}>
|
||||
const fChip = (key: string, label: string, cls: string, on: boolean, toggle: () => void, style?: React.CSSProperties, title?: string) => (
|
||||
<button key={key} type="button" onClick={toggle} title={title}
|
||||
className={cn(F_CHIP, on ? cls : `${cls} opacity-40 hover:opacity-80`)} style={style}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
// chipStyle builds a filter chip in an arbitrary colour — for the statuses
|
||||
// whose grid badge is not one of the semantic tokens the chip classes cover.
|
||||
// A filter that does not look like what it selects has to be learned twice.
|
||||
const chipStyle = (c: string): React.CSSProperties => ({
|
||||
color: c,
|
||||
borderColor: `color-mix(in srgb, ${c} 45%, transparent)`,
|
||||
background: `color-mix(in srgb, ${c} 14%, transparent)`,
|
||||
});
|
||||
|
||||
const renderClusterFilters = () => (
|
||||
<div className="w-56 shrink-0 border-l border-border/60 flex flex-col min-h-0 bg-muted/10">
|
||||
@@ -5931,14 +6212,14 @@ export default function App() {
|
||||
{ k: 'new-band-mode' as SpotFilterKey, label: 'NEW B+M', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
|
||||
{ k: 'new-band' as SpotFilterKey, label: 'NEW BAND', cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' },
|
||||
{ k: 'new-mode' as SpotFilterKey, label: 'NEW MODE', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||
{ k: 'new-slot' as SpotFilterKey, label: 'NEW SLOT', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||
{ k: 'new-slot' as SpotFilterKey, label: 'NEW SLOT', cls: 'border', style: chipStyle('#5AC8FA') },
|
||||
// NEW CALL is about the CALLSIGN, not the entity: never worked on this
|
||||
// band and mode. Only appears when the slot-highlight option is on.
|
||||
{ k: 'new-call' as SpotFilterKey, label: 'NEW CALL', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||
// Same colours as the badges in the grid — a filter that does not
|
||||
// look like what it selects has to be learned twice.
|
||||
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
||||
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
||||
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'border', style: chipStyle('var(--chart-5)') },
|
||||
{ k: 'new-pfx' as SpotFilterKey, label: 'NEW PFX', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||
// Only ever set for a station this receiver decoded over the UDP link.
|
||||
{ k: 'new-grid' as SpotFilterKey, label: 'NEW GRID', cls: 'bg-muted text-foreground border-border' },
|
||||
@@ -5946,8 +6227,9 @@ export default function App() {
|
||||
// worked spots; the separate "Hide worked" checkbox drops them — they
|
||||
// are opposite controls, so don't use both at once.
|
||||
{ k: 'worked' as SpotFilterKey, label: 'WORKED', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
|
||||
]).map((s) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k),
|
||||
() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })))}
|
||||
]).filter((s: any) => s.k !== 'new-pota' || chasePota())
|
||||
.map((s: any) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k),
|
||||
() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; }), s.style))}
|
||||
</div>,
|
||||
clusterStatusFilter.size > 0 ? (
|
||||
<button type="button" onClick={() => setClusterStatusFilter(new Set())}
|
||||
@@ -6092,13 +6374,19 @@ export default function App() {
|
||||
case 'cluster':
|
||||
return (
|
||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-2 py-1 border-b border-border/60 shrink-0">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Cluster</span>
|
||||
{clusterFiltersToggleBtn}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
<div className="flex-1 min-w-0 flex flex-col min-h-0">
|
||||
<ClusterGrid key={`clg-${activeProfileId ?? 'x'}`} rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} onSpotSelect={handleSpotSelect} />
|
||||
{/* Title, count and Filters ride INSIDE the grid's toolbar: two
|
||||
header rows cost a pane that is often only a few spots tall
|
||||
one of the few lines it has. */}
|
||||
<ClusterGrid key={`clg-${activeProfileId ?? 'x'}`} rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} onSpotSelect={handleSpotSelect}
|
||||
headerLeft={(
|
||||
<>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0">DX Cluster</span>
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">{spots.length} live</Badge>
|
||||
{clusterFiltersToggleBtn}
|
||||
</>
|
||||
)} />
|
||||
</div>
|
||||
{clusterShowFilters && renderClusterFilters()}
|
||||
</div>
|
||||
@@ -6133,12 +6421,25 @@ export default function App() {
|
||||
<ElecraftPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||
</div>
|
||||
);
|
||||
case 'tci':
|
||||
return (
|
||||
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
|
||||
<TCIPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||
</div>
|
||||
);
|
||||
case 'icom':
|
||||
return (
|
||||
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
|
||||
<IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||
</div>
|
||||
);
|
||||
case 'watchlist':
|
||||
return (
|
||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||
<WatchlistTab spots={spots} spotStatus={spotStatus}
|
||||
onSpotSelect={handleSpotSelect} onSpotClick={handleSpotClick} />
|
||||
</div>
|
||||
);
|
||||
case 'netcontrol':
|
||||
return (
|
||||
<div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
|
||||
@@ -6278,7 +6579,7 @@ export default function App() {
|
||||
disabled={disabled}
|
||||
onClick={() => p && goto(p.bearingShort, 'SP')}
|
||||
title={p
|
||||
? `Rotate short-path · ${Math.round(p.distanceShort).toLocaleString()} km`
|
||||
? `Rotate short-path · ${formatDistance(p.distanceShort)}`
|
||||
: (station.my_grid ? 'No remote grid' : 'Set your station grid in Preferences')}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 transition-colors',
|
||||
@@ -6294,7 +6595,7 @@ export default function App() {
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => p && goto(p.bearingLong, 'LP')}
|
||||
title={p ? `Rotate long-path · ${Math.round(p.distanceLong).toLocaleString()} km` : ''}
|
||||
title={p ? `Rotate long-path · ${formatDistance(p.distanceLong)}` : ''}
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 border-l border-info-border text-[10px] transition-colors',
|
||||
disabled
|
||||
@@ -7032,7 +7333,7 @@ export default function App() {
|
||||
// relative + absolute inner: the panel's content can't grow the row, so
|
||||
// the row height is set by the ENTRY STRIP. A taller tab (Awards/F3) then
|
||||
// scrolls inside this fixed height instead of pushing everything down.
|
||||
<div className="w-[560px] shrink-0 min-h-0 relative">
|
||||
<div className="w-[600px] shrink-0 min-h-0 relative">
|
||||
<div className="absolute inset-0 flex flex-col min-h-0">
|
||||
<DetailsPanel
|
||||
callsign={callsign}
|
||||
@@ -7051,6 +7352,7 @@ export default function App() {
|
||||
band={band}
|
||||
mode={mode}
|
||||
bands={bands}
|
||||
satellites={satellites}
|
||||
onEditQso={openEdit}
|
||||
{...(!callsign.trim() && selQso ? {
|
||||
slotCall: selQso.call, slotBand: selQso.band, slotMode: selQso.mode,
|
||||
@@ -7213,7 +7515,10 @@ export default function App() {
|
||||
entry, and the rig onto the frequency it was decoded on. */}
|
||||
<ChaseNewPanel onPick={(sp) => {
|
||||
onCallsignInput(sp.call, { force: true });
|
||||
if (sp.freq_hz) void tuneRigCAT(sp.freq_hz, sp.mode);
|
||||
// And the panadapter width too — after the tune settles, for the
|
||||
// same SmartSDR pan-follow reason as handleSpotClick.
|
||||
if (sp.freq_hz) void tuneRigCAT(sp.freq_hz, sp.mode).then(() =>
|
||||
window.setTimeout(() => FlexZoomForSpot(sp.mode ?? '', sp.freq_hz ?? 0).catch(() => {}), 300));
|
||||
}} onClose={() => { setShowChaseNew(false); writeUiPref('opslog.showChaseNew', '0'); }} />
|
||||
</div>
|
||||
)}
|
||||
@@ -7406,10 +7711,26 @@ export default function App() {
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{watchlistEnabled && (
|
||||
<TabsTrigger value="watchlist" className="gap-1.5">
|
||||
{t('tab.watchlist')}
|
||||
<span
|
||||
role="button"
|
||||
aria-label="Close Watchlist"
|
||||
title="Close"
|
||||
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||
onClick={(e) => { e.stopPropagation(); setWatchlistEnabled(false); setActiveTab((t) => (t === 'watchlist' ? 'recent' : t)); }}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{catState.backend === 'flex' && <TabsTrigger value="flex">Flex Console</TabsTrigger>}
|
||||
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom Console</TabsTrigger>}
|
||||
{catState.backend === 'yaesu' && <TabsTrigger value="yaesu">Yaesu Console</TabsTrigger>}
|
||||
{(catState.backend === 'elecraft' || catState.backend === 'kenwood') && <TabsTrigger value="elecraft">{t('k3.console')}</TabsTrigger>}
|
||||
{catState.backend === 'tci' && <TabsTrigger value="tci">{t('tcip.console')}</TabsTrigger>}
|
||||
{statsTabOpen && (
|
||||
<TabsTrigger value="stats" className="gap-1.5">
|
||||
{t('stats.tab')}
|
||||
@@ -8098,6 +8419,15 @@ export default function App() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* The SunSDR console. Everything on it is state the radio pushes
|
||||
over TCI unasked, so unlike the serial consoles it costs nothing
|
||||
to keep open. */}
|
||||
{catState.backend === 'tci' && (
|
||||
<TabsContent value="tci" className="flex-1 min-h-0 p-0">
|
||||
<TCIPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{catState.backend === 'icom' && (
|
||||
<TabsContent value="icom" className="flex-1 min-h-0 p-0">
|
||||
<IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||
@@ -8107,6 +8437,12 @@ export default function App() {
|
||||
{/* Band Map: several bands shown side-by-side (panadapter-style
|
||||
strips). Pick bands with the chips; each strip is clickable to
|
||||
tune the rig. */}
|
||||
{watchlistEnabled && (
|
||||
<TabsContent value="watchlist" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<WatchlistTab spots={spots} spotStatus={spotStatus}
|
||||
onSpotSelect={handleSpotSelect} onSpotClick={handleSpotClick} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{netEnabled && (
|
||||
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
|
||||
@@ -8253,11 +8589,16 @@ export default function App() {
|
||||
</span>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<Chip on={clusterUp} label="Cluster" title={clusterUp ? 'Cluster connected' : 'Cluster offline'} onClick={() => setActiveTab('cluster')} />
|
||||
<Chip
|
||||
on={catUp}
|
||||
label={<span className="inline-flex items-center gap-1"><RadioTower className="size-3" />{catUp ? (catState.rig || 'CAT') : (catState.enabled ? (shortCatError(catState.error) || 'CAT off') : 'CAT')}</span>}
|
||||
title={catUp ? `CAT: ${catState.rig || catState.backend || 'connected'}` : (catState.error || 'CAT')}
|
||||
onClick={() => { setSettingsSection('cat'); setShowSettings(true); }}
|
||||
{/* The CAT chip is also the radio switch.
|
||||
With one radio it behaves as it always did — click opens the CAT
|
||||
settings. With several it opens a menu: pick one and OpsLog
|
||||
reconnects to it, leaving the rest of the station alone. The
|
||||
answer to "I have two rigs" used to be "make two profiles",
|
||||
which moves the logbook with it. */}
|
||||
<RadioChip
|
||||
catUp={catUp}
|
||||
catState={catState}
|
||||
onOpenSettings={() => { setSettingsSection('cat'); setShowSettings(true); }}
|
||||
/>
|
||||
<Chip
|
||||
on={rotatorHeading.enabled && rotatorHeading.ok}
|
||||
@@ -8270,9 +8611,13 @@ export default function App() {
|
||||
STANDBY, red = offline. CLICK toggles OPERATE ↔ STANDBY (optimistic
|
||||
flip, the 2s poll reconciles); offline → click opens the settings. */}
|
||||
{ampSts.map((a: any) => {
|
||||
const isPGXL = !a.spe && !a.acom;
|
||||
// Every brand must be listed here. A KPA was not, so its chip
|
||||
// read the fallback — permanently red on a connected amplifier,
|
||||
// and clicking it opened the settings instead of switching to
|
||||
// STANDBY.
|
||||
const isPGXL = !a.spe && !a.acom && !a.kpa;
|
||||
const viaFlex = isPGXL && !!flexAmp?.amp_available;
|
||||
const raw = a.spe ?? a.acom ?? a.pgxl ?? { connected: false };
|
||||
const raw = a.spe ?? a.acom ?? a.kpa ?? a.pgxl ?? { connected: false };
|
||||
const connected = !!raw.connected || viaFlex;
|
||||
const operate = viaFlex ? !!flexAmp.amp_operate : !!raw.operate;
|
||||
const dot = !connected ? 'bg-danger' : operate ? 'bg-success' : 'bg-warning';
|
||||
@@ -8282,7 +8627,7 @@ export default function App() {
|
||||
const want = !operate;
|
||||
if (viaFlex) setFlexAmp((f: any) => ({ ...f, amp_operate: want }));
|
||||
else setAmpSts((l) => l.map((x: any) => x.id === a.id
|
||||
? { ...x, spe: x.spe && { ...x.spe, operate: want }, acom: x.acom && { ...x.acom, operate: want }, pgxl: x.pgxl && { ...x.pgxl, operate: want } }
|
||||
? { ...x, spe: x.spe && { ...x.spe, operate: want }, acom: x.acom && { ...x.acom, operate: want }, kpa: x.kpa && { ...x.kpa, operate: want }, pgxl: x.pgxl && { ...x.pgxl, operate: want } }
|
||||
: x));
|
||||
(viaFlex ? FlexAmpOperate(want) : AmpOperate(a.id, want)).catch(() => {});
|
||||
};
|
||||
@@ -8409,7 +8754,7 @@ export default function App() {
|
||||
"1.5k" and then appended the unit, giving "1.5kkm" — and even
|
||||
written correctly, "1.5k km" makes a reader do arithmetic to
|
||||
recover a number that was four characters long to begin with. */}
|
||||
<span className="font-mono opacity-80">{o.median_km} km</span>
|
||||
<span className="font-mono opacity-80">{formatDistance(o.median_km)}</span>
|
||||
{/* Out of season is the one an operator must not learn last, so it
|
||||
earns a mark on the badge rather than a line in the tooltip. */}
|
||||
{!o.in_season && <span className="opacity-90">!</span>}
|
||||
@@ -8454,9 +8799,14 @@ export default function App() {
|
||||
// freqMhz display string, which the manual-edit freeze / field locks can
|
||||
// leave stale (that dropped the sub-kHz: on 14134.5 the frozen "14.134"
|
||||
// string spotted 14134). Fall back to the entry field, then the last QSO.
|
||||
//
|
||||
// The RX frequency when split, deliberately: a spot names where the
|
||||
// STATION is, and in split that is where we listen — freq_hz is where
|
||||
// we transmit, and spotting a 5-up pile-up's TX frequency sent the
|
||||
// whole cluster five kHz above the DX.
|
||||
defaultFreqKHz={
|
||||
catState.connected && (catState.freq_hz ?? 0) > 0
|
||||
? Math.round(((catState.freq_hz ?? 0) / 1000) * 10) / 10
|
||||
catState.connected && ((catState.split && (catState.freq_rx_hz ?? 0) > 0 ? catState.freq_rx_hz : catState.freq_hz) ?? 0) > 0
|
||||
? Math.round((((catState.split && (catState.freq_rx_hz ?? 0) > 0 ? catState.freq_rx_hz : catState.freq_hz) ?? 0) / 1000) * 10) / 10
|
||||
: parseFloat(freqMhz) > 0
|
||||
? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10
|
||||
: (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0)
|
||||
@@ -8488,28 +8838,16 @@ export default function App() {
|
||||
|
||||
{showSettings && (
|
||||
<SettingsModal
|
||||
onEditQSO={openQSOFromSettings}
|
||||
initialSection={settingsSection}
|
||||
onClose={() => { setShowSettings(false); setSettingsSection(undefined); refreshChaseNew(); }}
|
||||
onSaved={() => {
|
||||
loadStation(); loadLists(); loadCATCfg(); reloadWk(); refreshManualRecReady();
|
||||
// Drop the cached spot statuses. They are computed once per
|
||||
// call+band+mode and never expire, so a rule change in Settings —
|
||||
// grouping the digital modes into one slot, above all — left every
|
||||
// spot already on screen showing the answer to the OLD question.
|
||||
setSpotStatus({});
|
||||
}}
|
||||
onMainPaneChanged={(side, v) => {
|
||||
// Applied from the CHOSEN value, never from a re-read of the DB:
|
||||
// the write is async and the layout must not lag a click behind.
|
||||
if (side === 'left') setMainPaneLeft(v as MainPaneKind);
|
||||
else if (side === 'right') setMainPaneRight(v as MainPaneKind);
|
||||
else if (side === 'p3') setMainPane3(v as MainPaneKind);
|
||||
else if (side === 'p4') setMainPane4(v as MainPaneKind);
|
||||
else if (side === 'layout') setMainLayout4(v === 'cols' ? 'cols' : 'quad');
|
||||
}}
|
||||
onClose={closeSettings}
|
||||
onSaved={onSettingsSaved}
|
||||
onMainPaneChanged={onSettingsPaneChanged}
|
||||
flexAvailable={catState.backend === 'flex'}
|
||||
icomAvailable={catState.backend === 'icom'}
|
||||
yaesuAvailable={catState.backend === 'yaesu'}
|
||||
elecraftAvailable={catState.backend === 'elecraft' || catState.backend === 'kenwood'}
|
||||
tciAvailable={catState.backend === 'tci'}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ function powerLevelLabel(pl?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
||||
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; kpa?: any; pgxl?: any };
|
||||
|
||||
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).
|
||||
@@ -64,6 +64,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
|
||||
const isSPE = !!amp.spe;
|
||||
const isACOM = !!amp.acom;
|
||||
const isKPA = !!amp.kpa;
|
||||
|
||||
if (isSPE) {
|
||||
const spe = amp.spe;
|
||||
@@ -127,7 +128,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
if (isACOM) {
|
||||
const acom = amp.acom;
|
||||
return (
|
||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `Acom${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" disabled={!acom.connected}
|
||||
onClick={() => AmpOperate(amp.id, !acom.operate).catch(() => {})}
|
||||
@@ -168,6 +169,53 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
);
|
||||
}
|
||||
|
||||
if (isKPA) {
|
||||
const kpa = amp.kpa;
|
||||
return (
|
||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || kpa.model || 'Elecraft'}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" disabled={!kpa.connected}
|
||||
onClick={() => AmpOperate(amp.id, !kpa.operate).catch(() => {})}
|
||||
title={kpa.fault_text ? t('flxp.kpaClearsFault') : undefined}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
kpa.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')}>
|
||||
{kpa.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
{/* The amplifier answers while its main supplies are off — a sleeping
|
||||
microcontroller stays awake for exactly that — so ON is offered
|
||||
over the network as well, unlike the SPE and Acom. */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!kpa.connected}
|
||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||
<button type="button" disabled={!kpa.connected}
|
||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||
</div>
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm', kpa.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||
<span className={cn('size-2 rounded-full', kpa.connected ? 'bg-success' : 'bg-danger')} />
|
||||
{kpa.connected ? (kpa.tuning ? t('flxp.kpaTuning') : (kpa.power_on ? 'ON' : 'OFF')) : t('flxp.kpaOffline')}
|
||||
</span>
|
||||
{kpa.connected && (
|
||||
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||
{kpa.band ? `${kpa.band} · ` : ''}{kpa.fwd_w}W · SWR {Number(kpa.swr ?? 0).toFixed(1)} · {kpa.temp_c}°C · {kpa.volt_v}V {kpa.cur_a}A
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{kpa.fault_text && (
|
||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {kpa.fault_text}</span>
|
||||
)}
|
||||
</div>
|
||||
{kpa.connected && (
|
||||
<MeterBar label={t('flxp.outputPower')} value={Number(kpa.fwd_w) || 0} unit="W"
|
||||
lo={0} hi={String(kpa.model).includes('500') ? 500 : 1500}
|
||||
display={`${Number(kpa.fwd_w) || 0} W`}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// PowerGenius XL — OPERATE + meters ride on the Flex; fan mode on the GSCP link.
|
||||
const pg = amp.pgxl || {};
|
||||
const viaFlex = !!flex?.amp_available;
|
||||
|
||||
@@ -15,7 +15,7 @@ import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from
|
||||
// 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 };
|
||||
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; kpa?: any; pgxl?: any };
|
||||
|
||||
// Full-scale watts for the output bar, from the model string.
|
||||
function maxW(model?: string, fallback = 1300): number {
|
||||
@@ -92,27 +92,39 @@ function OperateButton({ operate, disabled, onClick, t }: {
|
||||
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 spe = amp.spe, acom = amp.acom, kpaAmp = amp.kpa;
|
||||
const hold = usePeakHold();
|
||||
|
||||
if (spe || acom) {
|
||||
const s = spe || acom;
|
||||
// One block for the three amplifiers OpsLog drives over its own link. They
|
||||
// report the same handful of things under different names, so the differences
|
||||
// are named here rather than spread through the markup.
|
||||
if (spe || acom || kpaAmp) {
|
||||
const s = spe || acom || kpaAmp;
|
||||
// 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);
|
||||
// The full-scale mark. A KPA500 reading against a 1500 W scale would look
|
||||
// idle at full output, so the model decides it.
|
||||
const hi = spe ? maxW(s.model)
|
||||
: kpaAmp ? (String(s.model).includes('500') ? 500 : 1500)
|
||||
: (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');
|
||||
// A KPA answers ^ON while its main supplies are off — the sleeping
|
||||
// microcontroller stays awake for exactly that — so power-on is available
|
||||
// whenever the link itself is up, over serial and over the network alike.
|
||||
const canPowerOn = spe ? (s.connected || s.transport === 'serial')
|
||||
: kpaAmp ? !!s.connected
|
||||
: (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>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : kpaAmp ? (s.model || 'KPA') : 'Acom')}</span>
|
||||
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
|
||||
</div>
|
||||
)}
|
||||
@@ -155,11 +167,17 @@ function AmpBlock({ amp, flex, showName, t }: {
|
||||
) : (
|
||||
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||
)}
|
||||
{(s.warnings || s.alarms || s.err_text) && (
|
||||
{(s.warnings || s.alarms || s.err_text || s.fault_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()}
|
||||
{s.fault_text || s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
|
||||
</div>
|
||||
)}
|
||||
{/* Said where the fault is read, because it is the way out of it: on a
|
||||
KPA, going to OPERATE clears the current fault — everything except
|
||||
temperature, which clears by cooling. */}
|
||||
{kpaAmp && s.fault_text && (
|
||||
<div className="text-[9px] text-center text-muted-foreground">{t('ampw.kpaClear')}</div>
|
||||
)}
|
||||
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -127,6 +127,12 @@ function MatrixColorsSection() {
|
||||
<span className="inline-block w-7 h-5 rounded bg-mx-dx-work" />
|
||||
<span className="inline-block w-7 h-5 rounded bg-mx-none" />
|
||||
<span className="inline-block w-7 h-5 rounded bg-mx-none ring-2 ring-mx-cur ring-inset" />
|
||||
<span className="relative inline-block w-7 h-5 rounded bg-mx-dx-conf">
|
||||
<span className="absolute top-[4px] right-[4px] size-[5px] rounded-full bg-mx-mark-work ring-1 ring-background" />
|
||||
</span>
|
||||
<span className="relative inline-block w-7 h-5 rounded bg-mx-dx-conf">
|
||||
<span className="absolute top-[4px] right-[4px] size-[5px] rounded-full bg-mx-mark-conf ring-1 ring-background" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={reset}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { X, Plus, Loader2 } from 'lucide-react';
|
||||
import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta, AwardRefsNew } from '../../wailsjs/go/main/App';
|
||||
import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta, AwardRefsNew, GetTrackedAwards } from '../../wailsjs/go/main/App';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
||||
type AwardDef = { code: string; name: string; field?: string; dxcc_filter?: number[] | null; dynamic?: boolean };
|
||||
type AwardDef = { code: string; name: string; field?: string; dxcc_filter?: number[] | null; dynamic?: boolean; valid?: boolean };
|
||||
type Meta = { code: string; count: number; can_update: boolean };
|
||||
|
||||
// Fields auto-derived from structured QSO data — their awards (DXCC/WAZ/WAS/…)
|
||||
@@ -94,11 +94,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// The awards the operator FOLLOWS, exactly as the Awards tab reads them.
|
||||
// This panel used to offer every award that existed, so a station chasing
|
||||
// three of them was picking references out of a list of twenty.
|
||||
const [tracked, setTracked] = useState<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
|
||||
.then(([d, m]) => {
|
||||
Promise.all([GetAwardDefs(), GetAwardReferenceMeta(), GetTrackedAwards()])
|
||||
.then(([d, m, tr]) => {
|
||||
setDefs((d ?? []) as any);
|
||||
setMetas(Object.fromEntries(((m ?? []) as Meta[]).map((x) => [String(x.code).toUpperCase(), x])));
|
||||
setTracked(new Set(((tr ?? []) as string[]).map((c) => String(c).toUpperCase())));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
@@ -111,6 +116,12 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
return defs.filter((d) => {
|
||||
// Computed awards (field = dxcc/cqz/…) are derived automatically.
|
||||
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
|
||||
// Switched off in the award editor: not a candidate for anything.
|
||||
if (d.valid === false) return false;
|
||||
// Followed awards only, when a selection has been made. An empty
|
||||
// selection means "all of them" — the same rule the Awards tab uses, so
|
||||
// the two lists cannot disagree about what this station chases.
|
||||
if (tracked.size > 0 && !tracked.has(String(d.code).toUpperCase())) return false;
|
||||
const scope = d.dxcc_filter ?? [];
|
||||
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
|
||||
// Offer the award even when its reference list isn't loaded yet: a custom
|
||||
@@ -122,7 +133,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
return true;
|
||||
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
|
||||
.sort((a, b) => a.code.localeCompare(b.code));
|
||||
}, [defs, metas, dxcc]);
|
||||
}, [defs, metas, dxcc, tracked]);
|
||||
|
||||
// Keep the selected award valid as the offered list changes with the call.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -29,7 +29,32 @@ type AwardResult = {
|
||||
type AwardStatRow = { label: string; cells: number[]; total: number; grand_total: number };
|
||||
type AwardStats = { code: string; bands: string[]; rows: AwardStatRow[] };
|
||||
|
||||
// Fixed band columns for the matrix view (Log4OM-style).
|
||||
// Every band the matrix can show, in the order they belong in.
|
||||
//
|
||||
// The columns are the intersection of this with what the award covers, so a
|
||||
// band missing HERE cannot appear at all whatever the award says — which is how
|
||||
// DDFM, a French award worked well into the microwaves, ended at 70cm with no
|
||||
// 23cm column and no way to ask for one.
|
||||
//
|
||||
// Mirrors bandOrder in internal/award — the same list in frequency order. The
|
||||
// two are kept apart because they answer different questions (that one sorts an
|
||||
// award's own band counts, this one lays out columns), but a band added to one
|
||||
// belongs in the other.
|
||||
// The DXCC Challenge counts one point per CONFIRMED entity per band, over ten
|
||||
// bands — and 60 m is NOT one of them. That is the whole of the gap an operator
|
||||
// notices between OpsLog and LoTW: 3229 here against 3004 there, and 224 of the
|
||||
// difference is the 60 m column.
|
||||
//
|
||||
// Worked out from the confirmed row rather than asked of the backend, because
|
||||
// that row IS the answer: the Challenge is its sum over these ten bands.
|
||||
const CHALLENGE_BANDS = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
|
||||
|
||||
const ALL_BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m',
|
||||
'6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||
|
||||
// What to show when there is nothing to narrow it down: an award with no band
|
||||
// list and no contacts yet. The classic set — the whole of ALL_BANDS would be
|
||||
// twenty-nine columns of mostly nothing.
|
||||
const GRID_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm'];
|
||||
|
||||
// Per-band status for a reference, highest first.
|
||||
@@ -40,6 +65,19 @@ function cellStatus(r: AwardRef, band: string): CellStatus {
|
||||
if (r.bands?.includes(band)) return 'worked';
|
||||
return 'none';
|
||||
}
|
||||
// slotsToConfirm counts the band-slots worked with this reference and not yet
|
||||
// confirmed on any of them — the QSLs still outstanding, one per cell showing W.
|
||||
//
|
||||
// It is the difference the Challenge line makes visible in the aggregate (1832
|
||||
// worked against 1554 confirmed) without saying WHERE it is. Counted over the
|
||||
// bands actually on screen, so it always adds up to the columns in front of the
|
||||
// operator rather than to a band set they filtered out.
|
||||
function slotsToConfirm(r: AwardRef, bands: string[]): number {
|
||||
let n = 0;
|
||||
for (const b of bands) if (cellStatus(r, b) === 'worked') n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
const CELL_STYLE: Record<CellStatus, string> = {
|
||||
validated: 'bg-success text-success-foreground',
|
||||
confirmed: 'bg-warning text-warning-foreground',
|
||||
@@ -87,7 +125,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
const [refSearch, setRefSearch] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf' | 'slots_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.
|
||||
@@ -180,26 +218,46 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
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
|
||||
// award's explicit valid_bands if it has any (e.g. WAPC = 40/20/15/10m); else
|
||||
// the bands the operator actually has contacts on (so DXCC, which has no band
|
||||
// restriction, shows 160m–6m instead of empty VHF/UHF columns). Empty set =
|
||||
// no basis yet → callers fall back to all bands.
|
||||
// matrix so neither is padded with bands nothing was worked on. Rule: the
|
||||
// bands this operator HAS contacts on for this award; failing that, the
|
||||
// award's own list of permitted bands. Empty set = no basis at all → callers
|
||||
// fall back to the classic columns.
|
||||
const awardBands = useMemo(() => {
|
||||
const vb = (awardList.find((a) => a.code === selected)?.bands ?? []).map((b) => b.toLowerCase());
|
||||
if (vb.length > 0) return new Set(vb);
|
||||
const worked = (current?.bands ?? [])
|
||||
// The bands with CONTACTS on them come first, whatever the award declares.
|
||||
//
|
||||
// It used to take the declared list when there was one, which put up a
|
||||
// column for every band the award permits — DDFM allows 6m and 70cm, so
|
||||
// both stood there empty on a station that has never worked a French
|
||||
// department on either. An always-blank column reads as a gap in the log
|
||||
// rather than as a band the operator never tried.
|
||||
const worked = new Set((current?.bands ?? [])
|
||||
.filter((b) => (b.worked ?? 0) > 0)
|
||||
.map((b) => String(b.band).toLowerCase());
|
||||
return new Set(worked);
|
||||
.map((b) => String(b.band).toLowerCase()));
|
||||
if (worked.size > 0) return worked;
|
||||
// Nothing worked yet: the award's own list is the honest answer — it says
|
||||
// what this award is played on, which is the only thing there is to show.
|
||||
return new Set((awardList.find((a) => a.code === selected)?.bands ?? []).map((b) => b.toLowerCase()));
|
||||
}, [awardList, selected, current]);
|
||||
|
||||
const gridBands = useMemo(() => {
|
||||
if (awardBands.size === 0) return GRID_BANDS;
|
||||
const filtered = GRID_BANDS.filter((b) => awardBands.has(b));
|
||||
// Filtered from ALL_BANDS, so a band the award covers gets its column even
|
||||
// when it is one the classic set never listed.
|
||||
const filtered = ALL_BANDS.filter((b) => awardBands.has(b));
|
||||
return filtered.length ? filtered : GRID_BANDS;
|
||||
}, [awardBands]);
|
||||
|
||||
const challenge = useMemo(() => {
|
||||
if (!stats || selected.toUpperCase() !== 'DXCC') return null;
|
||||
const row = stats.rows.find((r) => r.label === 'CONFIRMED');
|
||||
if (!row) return null;
|
||||
let total = 0;
|
||||
stats.bands.forEach((b, i) => {
|
||||
if (CHALLENGE_BANDS.includes(String(b).toLowerCase())) total += Number(row.cells[i]) || 0;
|
||||
});
|
||||
return total;
|
||||
}, [stats, selected]);
|
||||
|
||||
// Stats rows to show: drop the mode-category rows (CW / DIGITAL / PHONE) the
|
||||
// award doesn't cover. The "ALL" rows (no suffix) always show; a category row
|
||||
// shows only when the award has no emission restriction or lists that emission.
|
||||
@@ -259,6 +317,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
if (refFilter === 'worked' && !r.worked) return false;
|
||||
if (refFilter === 'notworked' && r.worked) return false;
|
||||
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||
// Worked-not-confirmed by SLOT, not by reference: an entity confirmed on
|
||||
// 20 m still has a 15 m contact waiting for its card, and every filter
|
||||
// above answers "no" for it because the entity itself is confirmed.
|
||||
if (refFilter === 'slots_notconf' && slotsToConfirm(r, gridBands) === 0) 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
|
||||
@@ -294,7 +356,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
}
|
||||
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||
});
|
||||
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
|
||||
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir, gridBands]);
|
||||
|
||||
// The gap itself, over whatever the other filters left on screen: the number
|
||||
// of cells an operator would have to turn green to close it.
|
||||
const slotGap = useMemo(
|
||||
() => filteredRefs.reduce((n, r) => n + slotsToConfirm(r, gridBands), 0),
|
||||
[filteredRefs, gridBands],
|
||||
);
|
||||
|
||||
// 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
|
||||
@@ -319,7 +388,13 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
{t('awp.rescan')}
|
||||
</Button>
|
||||
</div>
|
||||
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); onAwardsChanged?.(); }} />
|
||||
{/* setRescanTick as well as setByCode. The grid was refreshed on save
|
||||
and the statistics matrix was not, so changing which confirmations
|
||||
count — LoTW only, LoTW plus cards — left the whole table showing
|
||||
the previous rule's numbers with nothing to say they were stale.
|
||||
That is how a setting comes to look as though it does nothing. */}
|
||||
<AwardEditor open={editing} onClose={() => setEditing(false)}
|
||||
onSaved={() => { setByCode({}); setRescanTick((t) => t + 1); loadList(); onAwardsChanged?.(); }} />
|
||||
{/* Quick selector — scales when there are many awards. */}
|
||||
{awardList.length > 0 && (
|
||||
<div className="px-2 py-2 border-b border-border/40">
|
||||
@@ -417,7 +492,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
<Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')]] as const).map(([k, label]) => (
|
||||
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')], ['slots_notconf', t('awp.filterSlotsNotCfmd')]] as const).map(([k, label]) => (
|
||||
<button key={k} onClick={() => setRefFilter(k)}
|
||||
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||
{label}
|
||||
@@ -433,6 +508,11 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||
{slotGap > 0 && (
|
||||
<span className="text-xs text-muted-foreground" title={t('awp.slotGapTip')}>
|
||||
· <span className="font-semibold text-foreground">{slotGap}</span> {t('awp.slotGap')}
|
||||
</span>
|
||||
)}
|
||||
{/* Only for an award scoped to a DXCC entity. "In this award's
|
||||
scope but with no reference" needs a scope to be in: on a
|
||||
worldwide reference award — POTA, SOTA, IOTA, WWFF — every
|
||||
@@ -473,8 +553,8 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
<tr className="bg-card">
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.statistic')}</th>
|
||||
{statsBandIdx.map((i) => <th key={stats.bands[i]} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{stats.bands[i]}</th>)}
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.total')}</th>
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.grand')}</th>
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center" title={t('awp.totalHint')}>{t('awp.total')}</th>
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center" title={t('awp.grandHint')}>{t('awp.grand')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -493,6 +573,19 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{challenge !== null && (
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td className="sticky left-0 bg-card py-1.5 pr-3 font-semibold whitespace-nowrap" title={t('awp.challengeHint')}>
|
||||
{t('awp.challenge')}
|
||||
</td>
|
||||
<td colSpan={statsBandIdx.length} className="py-1.5 text-[11px] text-muted-foreground">
|
||||
{t('awp.challengeBands')}
|
||||
</td>
|
||||
<td className="text-center py-1.5 px-2 font-mono font-bold text-success" colSpan={2}>{challenge}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
@@ -540,7 +633,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
|
||||
{s === 'none' ? <span className="block w-11 h-7" /> : (
|
||||
<button
|
||||
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
|
||||
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110',
|
||||
// Chasing the slots still to confirm, the
|
||||
// confirmed ones are context, not the answer:
|
||||
// a row is kept for its W cells and its V
|
||||
// cells would otherwise be the loudest thing
|
||||
// on it. Faded rather than hidden — which
|
||||
// band is already done is worth seeing.
|
||||
refFilter === 'slots_notconf' && s !== 'worked' && 'opacity-25')}
|
||||
title={t('awp.cellTitle', { ref: r.ref, band: b })}
|
||||
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
||||
>{CELL_LABEL[s]}</button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
|
||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
||||
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||
@@ -76,6 +77,13 @@ const BMP_MARKER_LABEL: Record<string, string> = {
|
||||
new_pota: 'bmp.legendNewPota',
|
||||
new_county: 'bmp.legendNewCounty',
|
||||
worked_call: 'bmp.legendWorkedCall',
|
||||
// Not on the strip — the pill is 22 px and a fourth segment turns it into a
|
||||
// colour code nobody reads — but the TOOLTIP has room, and leaving them out of
|
||||
// it made the two panels contradict each other: the cluster said NEW PFX about
|
||||
// a spot the map called "Worked". Both were true (the entity is worked on this
|
||||
// slot, the WPX prefix never has been) and neither view said so.
|
||||
new_pfx: 'clg2.newPfx',
|
||||
new_grid: 'clg2.newGrid',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -107,59 +115,26 @@ interface Props {
|
||||
keyNav?: boolean;
|
||||
}
|
||||
|
||||
const BAND_RANGES: Record<string, [number, number]> = {
|
||||
'160m': [1800, 2000],
|
||||
'80m': [3500, 3800],
|
||||
'60m': [5350, 5450],
|
||||
'40m': [7000, 7200],
|
||||
'30m': [10100, 10150],
|
||||
'20m': [14000, 14350],
|
||||
'17m': [18068, 18168],
|
||||
'15m': [21000, 21450],
|
||||
'12m': [24890, 24990],
|
||||
'10m': [28000, 29700],
|
||||
'6m': [50000, 50500],
|
||||
'4m': [70000, 70500],
|
||||
'2m': [144000, 146000],
|
||||
'70cm': [430000, 440000],
|
||||
// Band edges and mode segments come from lib/bandplan, which knows the
|
||||
// operator's IARU region (Settings → General): a Region 2 operator's 40 m runs
|
||||
// to 7300 with phone from 7125, and drawing Region 1's plan under their spots
|
||||
// cut the top 100 kHz of the band off the map.
|
||||
//
|
||||
// Sub-band shading colours: these are IDENTITIES (which mode the segment is
|
||||
// for), not states, so they take categorical hues — never the status tokens.
|
||||
// All three are drawn from the cool end of the categorical order and laid down
|
||||
// at low opacity, so the band plan stays background context while the warm
|
||||
// spot pills keep the foreground. Checked with | ||||