Compare commits
338
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cccdb354f8 | ||
|
|
4321eae647 | ||
|
|
03e71bfdf2 | ||
|
|
23323c91e0 | ||
|
|
8dbc4b7e62 | ||
|
|
e4a5d42b85 | ||
|
|
470eaf5d80 | ||
|
|
a41626955e | ||
|
|
be889681a9 | ||
|
|
f93e1c5898 | ||
|
|
2d67e3d57f | ||
|
|
1a32e4a228 | ||
|
|
b318aa66cc | ||
|
|
6cbe29fef1 | ||
|
|
96b5f2d91f | ||
|
|
ee004c1c62 | ||
|
|
b00552f617 | ||
|
|
4dbc773343 | ||
|
|
ec347e1b7a | ||
|
|
b0bbe3e402 | ||
|
|
85061ab673 | ||
|
|
ab68e4a84e | ||
|
|
f6532b2e85 | ||
|
|
8685dbd6cf | ||
|
|
b7c87def5b | ||
|
|
ae8e5b1bc6 | ||
|
|
ad69371f6a | ||
|
|
3f97084246 | ||
|
|
74dfc3a725 | ||
|
|
6442325926 | ||
|
|
4c4b3b6c2d | ||
|
|
4d8cb58550 | ||
|
|
a6172c4323 | ||
|
|
b70a679c64 | ||
|
|
f39bda110a | ||
|
|
ab8ecd65fe | ||
|
|
de0771d797 | ||
|
|
22e4266d38 | ||
|
|
e3332d1e27 | ||
|
|
5cebf163c5 | ||
|
|
cfd85ff9c3 | ||
|
|
c6f479750f | ||
|
|
386a8ad531 | ||
|
|
bcd7e409ba | ||
|
|
88f35e2c20 | ||
|
|
d2194da28e | ||
|
|
2f1d592497 | ||
|
|
2b6f1ba9d7 | ||
|
|
4a017f6290 | ||
|
|
7bab30aa71 | ||
|
|
77b95289e7 | ||
|
|
5b894b9dbc | ||
|
|
187ac9aa84 | ||
|
|
629bd8d84f | ||
|
|
7152d11007 | ||
|
|
a03d907128 | ||
|
|
91569e12f4 | ||
|
|
68f0d68980 | ||
|
|
8790b98766 | ||
|
|
8846cba40c | ||
|
|
0cc6ad686a | ||
|
|
612cb67438 | ||
|
|
cd5d8b503b | ||
|
|
aa59431403 | ||
|
|
269701410f | ||
|
|
3cb8096141 | ||
|
|
1f667e4a4b | ||
|
|
d5f7e290ab | ||
|
|
f2ba798764 | ||
|
|
d7ee87f22d | ||
|
|
2587e6bcfe | ||
|
|
755392e0fa | ||
|
|
a9fb428a0a | ||
|
|
bbb485b9cf | ||
|
|
3a94a69be3 | ||
|
|
e4028f4bcd | ||
|
|
7c10517bf5 | ||
|
|
cd4e5b1117 | ||
|
|
a13ac7d917 | ||
|
|
4e28098a4e | ||
|
|
52e98a71f4 | ||
|
|
81ff6a9b0e | ||
|
|
be52a23868 | ||
|
|
fc1561804c | ||
|
|
1288b3f998 | ||
|
|
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 | ||
|
|
e8dfa0eaaf | ||
|
|
b53c56d508 | ||
|
|
67c85be23c | ||
|
|
e41a3a4c1c | ||
|
|
e4276ff66e | ||
|
|
1e87081058 | ||
|
|
12b0a861e0 | ||
|
|
712d83a012 | ||
|
|
bd540ef18b | ||
|
|
2f2fced15c | ||
|
|
97ae05d688 | ||
|
|
df1d2767cd | ||
|
|
ed099a660e | ||
|
|
6536d140ba | ||
|
|
b2b93e2839 | ||
|
|
f9113b6ad3 | ||
|
|
e168ab4148 | ||
|
|
e4412955f0 | ||
|
|
78eb766789 | ||
|
|
d8064cc426 | ||
|
|
bd4f1b2325 | ||
|
|
b38b9030f0 |
@@ -1,6 +1,6 @@
|
|||||||
# OpsLog
|
# 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" />
|
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -39,6 +39,9 @@ Developed by **F4BPO**.
|
|||||||
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
||||||
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
|
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
|
||||||
ClubLog DXpedition date overrides.
|
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
|
- **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
|
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
|
**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 = two configurable panes** (per profile, Settings → General →
|
||||||
*Main view*): great-circle map, locator (street) map, the cluster grid, the
|
*Main view*): great-circle map, locator (street) map, the cluster grid, the
|
||||||
worked-before grid, recent QSOs, the **FlexRadio controls**, the **Icom
|
worked-before grid, recent QSOs, the **FT decodes**, or any of the radio
|
||||||
console** or the **Net control** panel.
|
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
|
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
||||||
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
basemaps (Light / Topo / Street / Satellite — Esri tiles, key-free and
|
||||||
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
labelled) and the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
||||||
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by
|
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by
|
||||||
**PstRotator** (UDP), a **4O3A Rotator Genius** (native TCP) or a **microHAM
|
**PstRotator** (UDP), a **4O3A Rotator Genius** (native TCP) or a **microHAM
|
||||||
ARCO** controlled natively — no PstRotator needed — over the **LAN** or **USB**
|
ARCO** controlled natively — no PstRotator needed — over the **LAN** or **USB**
|
||||||
@@ -87,8 +92,10 @@ Developed by **F4BPO**.
|
|||||||
|
|
||||||
## CAT control
|
## CAT control
|
||||||
|
|
||||||
Four native backends (Settings → CAT), each with auto-reconnect and a fast,
|
Six native backends (Settings → CAT), each with auto-reconnect and a fast,
|
||||||
non-blocking connect so a powered-off radio never freezes the app:
|
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.
|
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
||||||
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
- **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
|
- **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
|
via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or
|
||||||
Remote Utility needed.
|
Remote Utility needed.
|
||||||
- **TCI** (WebSocket) — SunSDR / ExpertSDR2 and any TCI-compatible server:
|
- **Kenwood / Elecraft** — native ASCII CAT over USB or an RS232-to-Ethernet
|
||||||
freq / mode / PTT / split, plus optional panorama spots.
|
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
|
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
|
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
|
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
|
||||||
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
||||||
|
|
||||||
|
### 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
|
### Icom control tab
|
||||||
|
|
||||||
Shown when the CAT backend is Icom (USB or network). A full RS-BA1-style console:
|
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
|
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
|
||||||
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
||||||
|
|
||||||
|
### 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 & switches
|
||||||
|
|
||||||
- **Amplifiers** — configure **one or several** amps (Settings → Amplifier is a
|
- **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,
|
**network** (RS232-to-Ethernet bridge) — operate/standby, ON/OFF,
|
||||||
Low / Mid / High output level, an output-power bar and live status (band,
|
Low / Mid / High output level, an output-power bar and live status (band,
|
||||||
SWR, PA current, temperature, warnings/alarms).
|
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
|
(RS232-to-Ethernet bridge) — operate/standby/off and live telemetry (forward
|
||||||
& reflected power, SWR, PA temperature, band, fan, faults). Power-ON works
|
& reflected power, SWR, PA temperature, band, fan, faults). Power-ON works
|
||||||
over a serial cable that wires the DTR/RTS lines.
|
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 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**,
|
- **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**,
|
||||||
**Ultrabeam** element control and **relay boards** — WebSwitch 1216H, KMTronic,
|
**Ultrabeam** element control and **relay boards** — WebSwitch 1216H, KMTronic,
|
||||||
**Denkovi** USB (FT245 D2XX bit-bang, 4 or 8 relays) and generic USB-serial
|
**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
|
reference assignment, live reference detection on call entry, **reference-list
|
||||||
import** for totals/names, and a **Rescan** that re-pulls the logbook (picks up
|
import** for totals/names, and a **Rescan** that re-pulls the logbook (picks up
|
||||||
fresh LoTW/QRZ confirmations).
|
fresh LoTW/QRZ confirmations).
|
||||||
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL — upload
|
- **Award statistics** by band and by mode class, with the **DXCC Challenge**
|
||||||
and **confirmation download** (which auto-refreshes the award stats).
|
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).
|
- **QSL Card Designer** (see below).
|
||||||
- **E-mail eQSL:** right-click a QSO → *Send eQSL by e-mail* via the configured
|
- **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
|
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
|
- **UDP emitters:** push the current frequency to **PstRotator**, radio info in
|
||||||
**N1MM `RadioInfo`** format, or an **ADIF record on each logged QSO** — so
|
**N1MM `RadioInfo`** format, or an **ADIF record on each logged QSO** — so
|
||||||
external tools (rotator control, digital apps, other loggers) stay in sync.
|
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
|
## 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
|
## Data & storage
|
||||||
|
|
||||||
- **Config** (settings, profiles, rigs/antennas, cluster nodes, lookup cache,
|
- **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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/dxped"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── DXpeditions (ADXO announcements + DX-World news) ────────────────────
|
||||||
|
//
|
||||||
|
// What a logger can say that a news reader cannot: whether the announced
|
||||||
|
// operation is worth chasing. Every activation is judged against THIS log —
|
||||||
|
// the same verdict the cluster paints on a spot — so the list reads as "what I
|
||||||
|
// still need", not "what is on the air".
|
||||||
|
|
||||||
|
// DXpedition is one announced operation plus what it is worth here.
|
||||||
|
type DXpedition struct {
|
||||||
|
dxped.Activation
|
||||||
|
// Status is the strongest verdict across the announced callsigns, bands and
|
||||||
|
// modes: "new" (entity never worked) beats "new-band-mode", which beats
|
||||||
|
// "new-band", "new-mode", "new-slot", and finally "worked". Empty when the
|
||||||
|
// callsign resolves to no entity — a prefix ADXO knows and cty.dat does not.
|
||||||
|
Status string `json:"status_chase"`
|
||||||
|
// Unconfirmed marks a need that is only a missing QSL, so the badge can be
|
||||||
|
// drawn dimmed exactly as it is in the cluster and the decode list.
|
||||||
|
Unconfirmed bool `json:"unconfirmed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// chaseRank orders the verdicts from "most worth chasing" down. The DXpedition
|
||||||
|
// list shows ONE badge, so the ranking is the whole decision.
|
||||||
|
var chaseRank = map[string]int{
|
||||||
|
"new": 6,
|
||||||
|
"new-band-mode": 5,
|
||||||
|
"new-band": 4,
|
||||||
|
"new-mode": 3,
|
||||||
|
"new-slot": 2,
|
||||||
|
"worked": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDXpeditions returns the announced operations, freshest feed permitting,
|
||||||
|
// each carrying its chase verdict.
|
||||||
|
func (a *App) GetDXpeditions() ([]DXpedition, error) {
|
||||||
|
if a.dxped == nil {
|
||||||
|
a.dxped = dxped.New()
|
||||||
|
}
|
||||||
|
acts, err := a.dxped.Activations(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("dxped: adxo fetch: %v", err)
|
||||||
|
if len(acts) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Stale data with a logged error beats an empty tab.
|
||||||
|
}
|
||||||
|
out := make([]DXpedition, 0, len(acts))
|
||||||
|
for _, act := range acts {
|
||||||
|
out = append(out, DXpedition{Activation: act, Status: "", Unconfirmed: false})
|
||||||
|
}
|
||||||
|
a.judgeDXpeditions(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgeDXpeditions fills in the chase verdict, in place.
|
||||||
|
//
|
||||||
|
// One ClusterSpotStatuses call for the whole list rather than one per row: the
|
||||||
|
// worked-index it builds is the expensive part (a full pass over the log), and
|
||||||
|
// asking it forty times to answer forty rows was the difference between a tab
|
||||||
|
// that opens and a tab that stalls a remote MySQL for a second.
|
||||||
|
func (a *App) judgeDXpeditions(list []DXpedition) {
|
||||||
|
if a.qso == nil || len(list) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type slot struct{ row int }
|
||||||
|
var queries []SpotQuery
|
||||||
|
var owners []slot
|
||||||
|
for i, d := range list {
|
||||||
|
calls := d.Calls
|
||||||
|
if len(calls) == 0 {
|
||||||
|
if c := strings.ToUpper(strings.TrimSpace(d.Callsign)); c != "" {
|
||||||
|
calls = []string{c}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, call := range calls {
|
||||||
|
// No announced band/mode: ask the entity-level question alone.
|
||||||
|
if len(d.Bands) == 0 && len(d.Modes) == 0 {
|
||||||
|
queries = append(queries, SpotQuery{Call: call})
|
||||||
|
owners = append(owners, slot{i})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bands := d.Bands
|
||||||
|
if len(bands) == 0 {
|
||||||
|
bands = []string{""}
|
||||||
|
}
|
||||||
|
modes := d.Modes
|
||||||
|
if len(modes) == 0 {
|
||||||
|
modes = []string{""}
|
||||||
|
}
|
||||||
|
for _, b := range bands {
|
||||||
|
for _, m := range modes {
|
||||||
|
queries = append(queries, SpotQuery{Call: call, Band: b, Mode: m})
|
||||||
|
owners = append(owners, slot{i})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(queries) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := a.ClusterSpotStatuses(queries)
|
||||||
|
for i, r := range res {
|
||||||
|
if i >= len(owners) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
row := owners[i].row
|
||||||
|
if chaseRank[r.Status] > chaseRank[list[row].Status] {
|
||||||
|
list[row].Status = r.Status
|
||||||
|
list[row].Unconfirmed = r.UnconfStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDXWorldNews returns the DX-World headlines, with the callsigns mined out
|
||||||
|
// of each one so the reader can watch them.
|
||||||
|
//
|
||||||
|
// Deliberately NOT judged against the log. A headline names no band, so the
|
||||||
|
// only verdict available is entity-level, and a pane of "NEW DXCC" and
|
||||||
|
// "worked" badges turned out to say nothing an operator could act on — the
|
||||||
|
// same two words on every row is noise wearing the clothes of information.
|
||||||
|
// The announcements pane, which knows the bands and modes, is where a chase
|
||||||
|
// verdict is worth drawing.
|
||||||
|
func (a *App) GetDXWorldNews() ([]dxped.News, error) {
|
||||||
|
if a.dxped == nil {
|
||||||
|
a.dxped = dxped.New()
|
||||||
|
}
|
||||||
|
news, err := a.dxped.News(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("dxped: dx-world fetch: %v", err)
|
||||||
|
if len(news) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return news, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshDXpeditions drops both caches so the next read goes to the network.
|
||||||
|
// Wired to the tab's refresh button: an operator who has just read of a landing
|
||||||
|
// on a cluster should not wait out the cache to see it here.
|
||||||
|
func (a *App) RefreshDXpeditions() {
|
||||||
|
if a.dxped == nil {
|
||||||
|
a.dxped = dxped.New()
|
||||||
|
}
|
||||||
|
a.dxped.Invalidate()
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ var deniedCallHashes = map[string]struct{}{
|
|||||||
"0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {},
|
"0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {},
|
||||||
"ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {},
|
"ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {},
|
||||||
"9d17c9c213a6cc89c12d7520bcf21c86a0cf43d17e82e74f33f3a77cb865d28a": {},
|
"9d17c9c213a6cc89c12d7520bcf21c86a0cf43d17e82e74f33f3a77cb865d28a": {},
|
||||||
|
"94ee059335e587e501cc4bf90613e0814f00a7b08bc7c648fd865a2af6a22cc2": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// callDenied reports whether a callsign is on deniedCallHashes. The call is
|
// callDenied reports whether a callsign is on deniedCallHashes. The call is
|
||||||
|
|||||||
@@ -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
|
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 {
|
func (a *App) SetKenwoodPower(w int) error {
|
||||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPower(w) })
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPower(w) })
|
||||||
}
|
}
|
||||||
@@ -80,6 +91,11 @@ func (a *App) SetKenwoodXIT(on bool) error {
|
|||||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodXIT(on) })
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodXIT(on) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NudgeKenwoodRIT moves the RIT/XIT offset by delta Hz.
|
||||||
|
func (a *App) NudgeKenwoodRIT(delta int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.NudgeKenwoodRIT(delta) })
|
||||||
|
}
|
||||||
|
|
||||||
// ClearKenwoodRIT zeroes the RIT and XIT offsets together, which is what the
|
// ClearKenwoodRIT zeroes the RIT and XIT offsets together, which is what the
|
||||||
// radio's own RC does and what an operator means by "clear it".
|
// radio's own RC does and what an operator means by "clear it".
|
||||||
func (a *App) ClearKenwoodRIT() error {
|
func (a *App) ClearKenwoodRIT() error {
|
||||||
|
|||||||
+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()
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ var sensitiveSettingKeys = map[string]bool{
|
|||||||
keyExtLoTWKeyPassword: true,
|
keyExtLoTWKeyPassword: true,
|
||||||
keyExtLoTWWebPassword: true,
|
keyExtLoTWWebPassword: true,
|
||||||
keyExtHRDLogCode: true,
|
keyExtHRDLogCode: true,
|
||||||
|
keyExtHamqthPassword: true,
|
||||||
keyExtEQSLPassword: true,
|
keyExtEQSLPassword: true,
|
||||||
keyExtCloudlogAPIKey: true,
|
keyExtCloudlogAPIKey: true,
|
||||||
// The web-publish config is one JSON blob and the FTP password lives inside
|
// The web-publish config is one JSON blob and the FTP password lives inside
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// TCI receive audio — bindings.
|
|
||||||
//
|
|
||||||
// A SunSDR carries its receive audio on the same WebSocket as its commands, so
|
|
||||||
// OpsLog can take it directly instead of asking the operator to install a
|
|
||||||
// virtual audio cable and wire ExpertSDR's output into it. This is the first
|
|
||||||
// half: RECEIVE only, which is what the QSO recorder and the CW decoder need.
|
|
||||||
// Transmit audio (the voice keyer) is the other half and is not here yet — it
|
|
||||||
// has to answer the radio's chrono packets at the right pace, and that is worth
|
|
||||||
// doing once the receive side has proved the format on a real radio.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
|
||||||
"hamlog/internal/cat"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StartTCIAudio opens the receive-audio stream for one receiver.
|
|
||||||
//
|
|
||||||
// rate 0 means 48 kHz, which is what ExpertSDR streams by default and what the
|
|
||||||
// recorder wants anyway.
|
|
||||||
func (a *App) StartTCIAudio(rx, rate int) error {
|
|
||||||
if a.cat == nil {
|
|
||||||
return fmt.Errorf("CAT not initialized")
|
|
||||||
}
|
|
||||||
applog.Printf("tci: opening the receive-audio stream (rx %d, %d Hz)", rx, rate)
|
|
||||||
return a.cat.TCIAudioDo(func(t cat.TCIAudioController) error { return t.StartTCIAudio(rx, rate) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopTCIAudio closes it.
|
|
||||||
func (a *App) StopTCIAudio() error {
|
|
||||||
if a.cat == nil {
|
|
||||||
return fmt.Errorf("CAT not initialized")
|
|
||||||
}
|
|
||||||
applog.Printf("tci: closing the receive-audio stream")
|
|
||||||
return a.cat.TCIAudioDo(func(t cat.TCIAudioController) error { return t.StopTCIAudio() })
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTCIAudioStatus reports what is arriving: the sample rate the radio chose,
|
|
||||||
// how much has come in, and the peak level of the last second.
|
|
||||||
//
|
|
||||||
// The level is the point. "The stream is open" and "audio is arriving" are
|
|
||||||
// different claims, and only the second one is worth anything to someone
|
|
||||||
// testing this on a radio for the first time.
|
|
||||||
func (a *App) GetTCIAudioStatus() cat.TCIAudioStatus {
|
|
||||||
if a.cat == nil {
|
|
||||||
return cat.TCIAudioStatus{}
|
|
||||||
}
|
|
||||||
st, _ := a.cat.TCIAudioState()
|
|
||||||
return st
|
|
||||||
}
|
|
||||||
@@ -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) })
|
||||||
|
}
|
||||||
+11
-33
@@ -68,10 +68,18 @@ func tciToRecorderPCM(rate int, samples []float32) []byte {
|
|||||||
// startTCIRecording opens the receive stream and routes it to the recorder.
|
// 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
|
// Called when the TCI backend comes up, and only when the operator has asked
|
||||||
// for it: opening a 384 kB/s stream on a station that records nothing is work
|
// for it by choosing the radio as their receive device: opening a 384 kB/s
|
||||||
// the radio does for nobody.
|
// stream on a station that records nothing is work the radio does for nobody.
|
||||||
func (a *App) startTCIRecording() {
|
func (a *App) startTCIRecording() {
|
||||||
if a.cat == nil || a.settingOr(keyTCIRecAudio, "") != "1" {
|
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
|
return
|
||||||
}
|
}
|
||||||
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
||||||
@@ -88,33 +96,3 @@ func (a *App) startTCIRecording() {
|
|||||||
}
|
}
|
||||||
applog.Printf("tci: recording the receive audio over TCI — no virtual cable needed")
|
applog.Printf("tci: recording the receive audio over TCI — no virtual cable needed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// keyTCIRecAudio turns it on. Off by default: it replaces whatever sound card
|
|
||||||
// the operator has already wired up, and a setting that changes where a
|
|
||||||
// recording comes from should be asked for rather than assumed.
|
|
||||||
const keyTCIRecAudio = "audio.tci_rx"
|
|
||||||
|
|
||||||
// GetTCIRecordAudio reports whether the recorder takes its audio from the radio.
|
|
||||||
func (a *App) GetTCIRecordAudio() bool { return a.settingOr(keyTCIRecAudio, "") == "1" }
|
|
||||||
|
|
||||||
// SetTCIRecordAudio turns it on or off, and applies it NOW rather than at the
|
|
||||||
// next restart: an option that needs the application relaunched to take effect
|
|
||||||
// reads as an option that does not work.
|
|
||||||
func (a *App) SetTCIRecordAudio(on bool) error {
|
|
||||||
a.setSetting(keyTCIRecAudio, boolStr(on))
|
|
||||||
if on {
|
|
||||||
a.startTCIRecording()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if a.cat == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
|
||||||
if s, ok := t.(interface {
|
|
||||||
SetTCIAudioSink(func(int, []float32))
|
|
||||||
}); ok {
|
|
||||||
s.SetTCIAudioSink(nil)
|
|
||||||
}
|
|
||||||
return t.StopTCIAudio()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// Recording a few seconds of the TCI stream to a WAV file.
|
|
||||||
//
|
|
||||||
// Counting frames proves a socket is delivering bytes. It does not prove those
|
|
||||||
// bytes are the receiver's audio, at the right rate, in the right order — a
|
|
||||||
// stream decoded with the channels swapped, the width wrong or the samples
|
|
||||||
// misaligned counts exactly as well as a correct one and sounds like a fan.
|
|
||||||
//
|
|
||||||
// So the test is a file the operator can play. It is the same reason the CW
|
|
||||||
// decoder was validated on the air rather than on a spectrogram.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
|
||||||
"hamlog/internal/cat"
|
|
||||||
)
|
|
||||||
|
|
||||||
// tciRec collects samples while a test recording is running.
|
|
||||||
type tciRec struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
active bool
|
|
||||||
rate int
|
|
||||||
samples []float32
|
|
||||||
want int // how many samples to collect before stopping
|
|
||||||
}
|
|
||||||
|
|
||||||
var tciRecorder tciRec
|
|
||||||
|
|
||||||
// RecordTCIAudio captures seconds of the TCI receive stream and writes a WAV
|
|
||||||
// next to the QSO recordings. Returns the path.
|
|
||||||
//
|
|
||||||
// The stream has to be open already — this listens to what is arriving rather
|
|
||||||
// than opening anything, so a recording can never leave a stream running that
|
|
||||||
// the operator did not ask for.
|
|
||||||
func (a *App) RecordTCIAudio(seconds int) (string, error) {
|
|
||||||
if a.cat == nil {
|
|
||||||
return "", fmt.Errorf("CAT not initialized")
|
|
||||||
}
|
|
||||||
if seconds <= 0 || seconds > 60 {
|
|
||||||
seconds = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate from the radio, not assumed: the file's header has to match what was
|
|
||||||
// actually streamed or the recording plays at the wrong speed, which is the
|
|
||||||
// one fault that would be blamed on the decoding.
|
|
||||||
st := a.GetTCIAudioStatus()
|
|
||||||
if !st.Running {
|
|
||||||
return "", fmt.Errorf("open the TCI audio stream first")
|
|
||||||
}
|
|
||||||
rate := st.SampleRate
|
|
||||||
if rate <= 0 {
|
|
||||||
rate = 48000
|
|
||||||
}
|
|
||||||
|
|
||||||
tciRecorder.mu.Lock()
|
|
||||||
if tciRecorder.active {
|
|
||||||
tciRecorder.mu.Unlock()
|
|
||||||
return "", fmt.Errorf("a test recording is already running")
|
|
||||||
}
|
|
||||||
tciRecorder.active = true
|
|
||||||
tciRecorder.rate = rate
|
|
||||||
tciRecorder.want = rate * seconds
|
|
||||||
tciRecorder.samples = make([]float32, 0, tciRecorder.want)
|
|
||||||
tciRecorder.mu.Unlock()
|
|
||||||
|
|
||||||
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
|
||||||
s, ok := t.(interface{ SetTCIAudioSink(func(int, []float32)) })
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("this backend has no audio sink")
|
|
||||||
}
|
|
||||||
s.SetTCIAudioSink(func(_ int, samples []float32) {
|
|
||||||
tciRecorder.mu.Lock()
|
|
||||||
defer tciRecorder.mu.Unlock()
|
|
||||||
if !tciRecorder.active {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tciRecorder.samples = append(tciRecorder.samples, samples...)
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
tciRecorder.mu.Lock()
|
|
||||||
tciRecorder.active = false
|
|
||||||
tciRecorder.mu.Unlock()
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for the samples rather than for the clock: a stream that stalls
|
|
||||||
// halfway should produce a short file that says so, not a long one padded
|
|
||||||
// with silence that hides it.
|
|
||||||
deadline := time.Now().Add(time.Duration(seconds+5) * time.Second)
|
|
||||||
for {
|
|
||||||
tciRecorder.mu.Lock()
|
|
||||||
got := len(tciRecorder.samples)
|
|
||||||
want := tciRecorder.want
|
|
||||||
tciRecorder.mu.Unlock()
|
|
||||||
if got >= want || time.Now().After(deadline) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
tciRecorder.mu.Lock()
|
|
||||||
tciRecorder.active = false
|
|
||||||
pcm := tciRecorder.samples
|
|
||||||
tciRecorder.samples = nil
|
|
||||||
tciRecorder.mu.Unlock()
|
|
||||||
_ = a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
|
||||||
if s, ok := t.(interface{ SetTCIAudioSink(func(int, []float32)) }); ok {
|
|
||||||
s.SetTCIAudioSink(nil)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(pcm) == 0 {
|
|
||||||
return "", fmt.Errorf("nothing arrived on the stream")
|
|
||||||
}
|
|
||||||
path := filepath.Join(a.qsoRecDir(), fmt.Sprintf("tci-test-%s.wav", time.Now().Format("20060102-150405")))
|
|
||||||
if err := writeMonoWAV(path, pcm, rate); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
applog.Printf("tci: wrote %.1f s of receive audio to %s (%d Hz)", float64(len(pcm))/float64(rate), path, rate)
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeMonoWAV writes float samples as 16-bit mono PCM.
|
|
||||||
//
|
|
||||||
// Its own writer rather than internal/audio's: that one is nailed to the voice
|
|
||||||
// keyer's rate, and a test recording written at the wrong rate would play back
|
|
||||||
// at the wrong speed — the one fault that looks exactly like a decoding error.
|
|
||||||
func writeMonoWAV(path string, samples []float32, rate int) error {
|
|
||||||
data := make([]byte, len(samples)*2)
|
|
||||||
for i, v := range samples {
|
|
||||||
s := int(math.Round(float64(v) * 32767))
|
|
||||||
if s > 32767 {
|
|
||||||
s = 32767
|
|
||||||
}
|
|
||||||
if s < -32768 {
|
|
||||||
s = -32768
|
|
||||||
}
|
|
||||||
binary.LittleEndian.PutUint16(data[i*2:], uint16(int16(s)))
|
|
||||||
}
|
|
||||||
var hdr [44]byte
|
|
||||||
copy(hdr[0:], "RIFF")
|
|
||||||
binary.LittleEndian.PutUint32(hdr[4:], uint32(36+len(data)))
|
|
||||||
copy(hdr[8:], "WAVEfmt ")
|
|
||||||
binary.LittleEndian.PutUint32(hdr[16:], 16) // PCM chunk size
|
|
||||||
binary.LittleEndian.PutUint16(hdr[20:], 1) // PCM
|
|
||||||
binary.LittleEndian.PutUint16(hdr[22:], 1) // mono
|
|
||||||
binary.LittleEndian.PutUint32(hdr[24:], uint32(rate))
|
|
||||||
binary.LittleEndian.PutUint32(hdr[28:], uint32(rate*2))
|
|
||||||
binary.LittleEndian.PutUint16(hdr[32:], 2) // block align
|
|
||||||
binary.LittleEndian.PutUint16(hdr[34:], 16) // bits
|
|
||||||
copy(hdr[36:], "data")
|
|
||||||
binary.LittleEndian.PutUint32(hdr[40:], uint32(len(data)))
|
|
||||||
|
|
||||||
f, err := os.Create(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
if _, err := f.Write(hdr[:]); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = f.Write(data)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The named contest callsigns: the other half of the auto-add, and the half the
|
||||||
|
// pattern cannot express.
|
||||||
|
//
|
||||||
|
// A special-event fleet is usually a common string in the callsign — WWA in
|
||||||
|
// TM29WWA, HB9WWA, F4WWA/P — and the pattern collects those on sight. But an
|
||||||
|
// entry list is not a naming convention: R7W can be part of the same event and
|
||||||
|
// share nothing with it, and no pattern will ever catch that station without
|
||||||
|
// catching half the band with it. So the two work together: the pattern for the
|
||||||
|
// family, this list for everybody else.
|
||||||
|
//
|
||||||
|
// Stored GLOBALLY, like the pattern and the list itself — an event is worth
|
||||||
|
// hunting whichever station profile is on.
|
||||||
|
func (a *App) GetWatchlistContestCalls() string {
|
||||||
|
return a.settingOr(keyWatchlistContestCalls, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWatchlistContestCalls stores the list as typed and caches the parsed set.
|
||||||
|
//
|
||||||
|
// The RAW text is what is stored: the operator's line breaks and their order
|
||||||
|
// are how the list is read back a week later, and rewriting it into a
|
||||||
|
// normalised single line loses the only structure it has.
|
||||||
|
func (a *App) SetWatchlistContestCalls(list string) {
|
||||||
|
a.setSettingGlobal(keyWatchlistContestCalls, list)
|
||||||
|
a.watchCalls.Store(parseContestCalls(list))
|
||||||
|
applog.Printf("watchlist: %d named contest callsigns", len(parseContestCalls(list)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseContestCalls splits the box into a set. One per line is what is asked
|
||||||
|
// for, and commas, semicolons and spaces are accepted too: a list pasted from
|
||||||
|
// an announcement arrives in whatever shape the announcement used.
|
||||||
|
func parseContestCalls(list string) map[string]struct{} {
|
||||||
|
out := map[string]struct{}{}
|
||||||
|
for _, tok := range strings.FieldsFunc(strings.ToUpper(list), func(r rune) bool {
|
||||||
|
return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||||
|
}) {
|
||||||
|
if tok = strings.TrimSpace(tok); tok != "" {
|
||||||
|
out[tok] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNamedContestCall answers the hot path from the cached set.
|
||||||
|
func (a *App) isNamedContestCall(call string) bool {
|
||||||
|
v := a.watchCalls.Load()
|
||||||
|
if v == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
set, ok := v.(map[string]struct{})
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, found := set[strings.ToUpper(strings.TrimSpace(call))]
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
if err := a.watchlist.Add(callsign, contest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.notifyWatchlist(callsign, true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchlistRemove deletes an entry.
|
||||||
|
func (a *App) WatchlistRemove(callsign string) error {
|
||||||
|
if a.watchlist == nil {
|
||||||
|
return fmt.Errorf("watchlist not initialized")
|
||||||
|
}
|
||||||
|
if err := a.watchlist.Remove(callsign); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.notifyWatchlist(callsign, false)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyWatchlist announces a membership change to the UI.
|
||||||
|
//
|
||||||
|
// Emitted from the BINDINGS rather than from the watchlist panel, because a
|
||||||
|
// call can now be added from three places (the panel, the cluster's menu, the
|
||||||
|
// DXpeditions tab) and an operator who added one from the cluster saw nothing
|
||||||
|
// at all — the confirmation lived inside a panel they were not looking at.
|
||||||
|
func (a *App) notifyWatchlist(callsign string, added bool) {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wruntime.EventsEmit(a.ctx, "watchlist:changed", map[string]any{
|
||||||
|
"call": strings.ToUpper(strings.TrimSpace(callsign)),
|
||||||
|
"added": added,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 contest pattern or the named list may claim it.
|
||||||
|
// Contains, not prefix, for the pattern: the event string sits anywhere
|
||||||
|
// in those calls (HB9WWA, F4WWA/P). The named list is exact, and is what
|
||||||
|
// catches the entries whose callsign says nothing about the event.
|
||||||
|
p := a.GetWatchlistContestPattern()
|
||||||
|
byPattern := p != "" && strings.Contains(strings.ToUpper(dxCall), p)
|
||||||
|
byName := a.isNamedContestCall(dxCall)
|
||||||
|
if byPattern || byName {
|
||||||
|
if err := a.watchlist.Add(dxCall, true); err == nil {
|
||||||
|
why := fmt.Sprintf("contest pattern %q", p)
|
||||||
|
if byName {
|
||||||
|
why = "named in the contest list"
|
||||||
|
}
|
||||||
|
applog.Printf("watchlist: auto-added %s (%s)", dxCall, why)
|
||||||
|
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,215 @@
|
|||||||
|
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
|
||||||
|
keyWsjtHLWorked = "udp.wsjt.highlight_worked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
// Worked already, on this band and in this mode. Grey on purpose, and the
|
||||||
|
// only DIM colour of the four: the others say "look at this", and this one
|
||||||
|
// says the opposite — it has to recede, not compete with them.
|
||||||
|
hlWorked = udp.RGB{R: 75, G: 85, B: 99}
|
||||||
|
hlWorkedFg = udp.RGB{R: 203, G: 213, B: 225}
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetWsjtHighlightWorked reports whether stations already worked on this band
|
||||||
|
// and mode are greyed out as well.
|
||||||
|
//
|
||||||
|
// Its own switch, and off by default. The other three verdicts pick out a
|
||||||
|
// handful of decodes in a period; this one can match most of them on a
|
||||||
|
// well-filled log, and a screen where nearly every line is coloured has stopped
|
||||||
|
// saying anything. It is worth having only for an operator who wants the dupes
|
||||||
|
// struck out rather than the catches picked out.
|
||||||
|
func (a *App) GetWsjtHighlightWorked() bool {
|
||||||
|
return a.settingOr(keyWsjtHLWorked, "0") == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWsjtHighlightWorked flips it, and repaints.
|
||||||
|
//
|
||||||
|
// Turning it OFF has to clear what it painted: those callsigns keep their grey
|
||||||
|
// in the decoder's window otherwise, and nothing would ever say another word
|
||||||
|
// about them — the de-duplication remembers that they were already told.
|
||||||
|
func (a *App) SetWsjtHighlightWorked(on bool) {
|
||||||
|
v := "0"
|
||||||
|
if on {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
a.setSetting(keyWsjtHLWorked, v)
|
||||||
|
a.wsjtHLWorkedOn.Store(on)
|
||||||
|
a.clearWsjtHighlights()
|
||||||
|
applog.Printf("wsjt highlight: worked stations %v", on)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearWsjtHighlights wipes every instruction OpsLog installed, in every
|
||||||
|
// running decoder, and forgets what it had said.
|
||||||
|
func (a *App) clearWsjtHighlights() {
|
||||||
|
if a.udp == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, inst := range a.udp.Instances() {
|
||||||
|
_ = a.udp.SendClearHighlights(inst)
|
||||||
|
}
|
||||||
|
a.wsjtHLMu.Lock()
|
||||||
|
a.wsjtHLSent = map[string]string{}
|
||||||
|
a.wsjtHLMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.clearWsjtHighlights()
|
||||||
|
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, mode string) {
|
||||||
|
if !a.wsjtHighlightOn.Load() || a.udp == nil || call == "" || instance == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bg, fg, verdict := a.decodeHighlightVerdict(call, band, mode)
|
||||||
|
// The MODE is part of the key: one receiver can be handed FT8 and FT4 in
|
||||||
|
// the same session, and the verdict on a callsign is not the same in both.
|
||||||
|
key := instance + "|" + strings.ToUpper(call) + "|" + band + "|" + strings.ToUpper(mode)
|
||||||
|
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, and "already worked here" comes last of all — it is the only
|
||||||
|
// verdict that says do NOT call, so anything worth calling for outranks it.
|
||||||
|
// Anything else is "no colour", and the empty verdict doubles as the clear
|
||||||
|
// signal in maybeHighlightDecode.
|
||||||
|
func (a *App) decodeHighlightVerdict(call, band, mode 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Worked already, this exact callsign on this band in this mode — a dupe,
|
||||||
|
// judged by the same ledger and the same digital-mode grouping the cluster
|
||||||
|
// uses, so the two windows cannot disagree about what "worked" means.
|
||||||
|
if a.wsjtHLWorkedOn.Load() && band != "" && mode != "" {
|
||||||
|
m := strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
if c.normMode != nil {
|
||||||
|
m = c.normMode(m)
|
||||||
|
}
|
||||||
|
if _, ok := c.workedCallSlots[strings.ToUpper(call)+"|"+strings.ToLower(band)+"|"+m]; ok {
|
||||||
|
bgc, fgc := hlWorked, hlWorkedFg
|
||||||
|
return &bgc, &fgc, "worked"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil, ""
|
||||||
|
}
|
||||||
@@ -174,6 +174,11 @@ type MatrixColors struct {
|
|||||||
EntityWorked string `json:"entity_worked"`
|
EntityWorked string `json:"entity_worked"`
|
||||||
NotWorked string `json:"not_worked"`
|
NotWorked string `json:"not_worked"`
|
||||||
CurrentEntry string `json:"current_entry"`
|
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.
|
// normMatrixColors keeps only plain hex values. Anything else becomes "" — i.e.
|
||||||
@@ -196,6 +201,8 @@ func normMatrixColors(c MatrixColors) MatrixColors {
|
|||||||
EntityWorked: clean(c.EntityWorked),
|
EntityWorked: clean(c.EntityWorked),
|
||||||
NotWorked: clean(c.NotWorked),
|
NotWorked: clean(c.NotWorked),
|
||||||
CurrentEntry: clean(c.CurrentEntry),
|
CurrentEntry: clean(c.CurrentEntry),
|
||||||
|
MarkWorked: clean(c.MarkWorked),
|
||||||
|
MarkConfirmed: clean(c.MarkConfirmed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+688
@@ -0,0 +1,688 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Auto-call — the wiring around internal/autocall.
|
||||||
|
//
|
||||||
|
// The DECISION is in that package, alone and tested. This file does the three
|
||||||
|
// things it cannot do for itself: cut the decode stream into periods, tell it
|
||||||
|
// what the log still needs from each station, and carry out what it decides.
|
||||||
|
//
|
||||||
|
// It lives in the backend rather than in the panel because it keys a
|
||||||
|
// transmitter: it must behave identically whether the FT decodes tab is open,
|
||||||
|
// behind another tab, or the window is minimised — and because every rule it
|
||||||
|
// applies is then a Go test rather than something only the air can check.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/autocall"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyAutoCallOn = "autocall.enabled"
|
||||||
|
keyAutoCallOnly = "autocall.only"
|
||||||
|
keyAutoCallAttempts = "autocall.attempts"
|
||||||
|
keyAutoCallWatched = "autocall.watched_attempts"
|
||||||
|
keyAutoCallMisses = "autocall.misses"
|
||||||
|
keyAutoCallRounds = "autocall.max_rounds"
|
||||||
|
keyAutoCallRestMin = "autocall.rest_min"
|
||||||
|
keyAutoCallOnScreen = "autocall.on_screen_only"
|
||||||
|
keyAutoCallTrace = "autocall.trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AutoCallSettings is the panel's shape. Durations are in minutes because that
|
||||||
|
// is what the operator is asked for.
|
||||||
|
type AutoCallSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Only string `json:"only"`
|
||||||
|
// Attempts / WatchedAttempts: how many calls one station gets before it is
|
||||||
|
// released. The larger allowance is for a callsign on the watch list.
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
WatchedAttempts int `json:"watched_attempts"`
|
||||||
|
// Misses: periods in which the station itself transmits, with no decode of
|
||||||
|
// it, before it is given up on.
|
||||||
|
Misses int `json:"misses"`
|
||||||
|
// MaxRounds: how many series of calls one station gets in a session, and
|
||||||
|
// RestMin the pause between two of them.
|
||||||
|
MaxRounds int `json:"max_rounds"`
|
||||||
|
RestMin int `json:"rest_min"`
|
||||||
|
// OnScreenOnly: call only what the decodes panel is showing, so its filters
|
||||||
|
// steer the transmitter as well as the eye.
|
||||||
|
OnScreenOnly bool `json:"on_screen_only"`
|
||||||
|
// Trace writes one line per period to the log: what was on the air, why
|
||||||
|
// each station was refused, and what was decided. For diagnosing "it is not
|
||||||
|
// calling anything" — and it is a line every fifteen seconds, so it is off
|
||||||
|
// unless asked for.
|
||||||
|
Trace bool `json:"trace"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetAutoCallSettings() AutoCallSettings {
|
||||||
|
d := autocall.Defaults()
|
||||||
|
num := func(key string, def int) int {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(a.settingOr(key, "")))
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return AutoCallSettings{
|
||||||
|
// Never on from a stored value alone — see startAutoCall.
|
||||||
|
Enabled: a.settingOr(keyAutoCallOn, "0") == "1",
|
||||||
|
Only: strings.ToUpper(strings.TrimSpace(a.settingOr(keyAutoCallOnly, ""))),
|
||||||
|
Attempts: num(keyAutoCallAttempts, d.Attempts),
|
||||||
|
WatchedAttempts: num(keyAutoCallWatched, d.WatchedAttempts),
|
||||||
|
// On by default: the filters are in front of the operator, and a station
|
||||||
|
// they have hidden is one they have said they do not want.
|
||||||
|
OnScreenOnly: a.settingOr(keyAutoCallOnScreen, "1") == "1",
|
||||||
|
Trace: a.settingOr(keyAutoCallTrace, "0") == "1",
|
||||||
|
Misses: num(keyAutoCallMisses, d.Misses),
|
||||||
|
MaxRounds: num(keyAutoCallRounds, d.MaxRounds),
|
||||||
|
RestMin: num(keyAutoCallRestMin, int(d.Rest/time.Minute)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SaveAutoCallSettings(s AutoCallSettings) error {
|
||||||
|
a.setSetting(keyAutoCallOn, map[bool]string{true: "1", false: "0"}[s.Enabled])
|
||||||
|
a.setSetting(keyAutoCallOnly, strings.ToUpper(strings.TrimSpace(s.Only)))
|
||||||
|
a.setSetting(keyAutoCallOnScreen, map[bool]string{true: "1", false: "0"}[s.OnScreenOnly])
|
||||||
|
a.setSetting(keyAutoCallTrace, map[bool]string{true: "1", false: "0"}[s.Trace])
|
||||||
|
for key, v := range map[string]int{
|
||||||
|
keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts,
|
||||||
|
keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds,
|
||||||
|
keyAutoCallRestMin: s.RestMin,
|
||||||
|
} {
|
||||||
|
if v > 0 {
|
||||||
|
a.setSetting(key, strconv.Itoa(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.applyAutoCall()
|
||||||
|
applog.Printf("autocall: %v (only=%q, %d/%d calls, %d misses, %d rounds)",
|
||||||
|
s.Enabled, s.Only, s.Attempts, s.WatchedAttempts, s.Misses, s.MaxRounds)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAutoCallOnly is the chase-list field in the decodes toolbar.
|
||||||
|
//
|
||||||
|
// Its own binding rather than a settings round-trip: the toolbar knows one
|
||||||
|
// field, and handing back a whole struct it never read is how a Preferences
|
||||||
|
// window left open somewhere quietly reverts a limit that was just changed.
|
||||||
|
func (a *App) SetAutoCallOnly(list string) error {
|
||||||
|
s := a.GetAutoCallSettings()
|
||||||
|
s.Only = list
|
||||||
|
return a.SaveAutoCallSettings(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAutoCall is the toolbar switch above the decodes.
|
||||||
|
func (a *App) SetAutoCall(on bool) error {
|
||||||
|
s := a.GetAutoCallSettings()
|
||||||
|
s.Enabled = on
|
||||||
|
return a.SaveAutoCallSettings(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallEngine returns the engine, built on first use.
|
||||||
|
func (a *App) autoCallEngine() *autocall.Engine {
|
||||||
|
a.acMu.Lock()
|
||||||
|
defer a.acMu.Unlock()
|
||||||
|
if a.ac == nil {
|
||||||
|
a.ac = autocall.New(a.autoCallSettings())
|
||||||
|
}
|
||||||
|
return a.ac
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) autoCallSettings() autocall.Settings {
|
||||||
|
s := a.GetAutoCallSettings()
|
||||||
|
return autocall.Settings{
|
||||||
|
Enabled: s.Enabled, Only: s.Only, OnScreenOnly: s.OnScreenOnly,
|
||||||
|
Attempts: s.Attempts, WatchedAttempts: s.WatchedAttempts,
|
||||||
|
Misses: s.Misses, MaxRounds: s.MaxRounds,
|
||||||
|
Rest: time.Duration(s.RestMin) * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAutoCall pushes the settings into the engine, and clears its state when
|
||||||
|
// the feature is switched off — an operator turning it off is entitled to have
|
||||||
|
// it forget the station it was calling, not resume it half an hour later.
|
||||||
|
func (a *App) applyAutoCall() {
|
||||||
|
e := a.autoCallEngine()
|
||||||
|
s := a.autoCallSettings()
|
||||||
|
e.SetSettings(s)
|
||||||
|
if a.GetAutoCallSettings().Trace {
|
||||||
|
e.SetTrace(func(f string, args ...any) { applog.Printf("autocall: "+f, args...) })
|
||||||
|
} else {
|
||||||
|
e.SetTrace(nil)
|
||||||
|
}
|
||||||
|
if !s.Enabled {
|
||||||
|
e.Reset()
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acPeriod, a.acBuf = nil, nil
|
||||||
|
a.acMu.Unlock()
|
||||||
|
}
|
||||||
|
a.emitAutoCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetAutoCall is the operator's restart after the engine gave up on an
|
||||||
|
// explicit target: it clears every verdict, including the grey list.
|
||||||
|
func (a *App) ResetAutoCall() {
|
||||||
|
a.autoCallEngine().Reset()
|
||||||
|
a.emitAutoCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HaltAutoCall is the Halt button while a call is in progress.
|
||||||
|
//
|
||||||
|
// It does NOT clear the engine's state, which is what Halt used to do: that
|
||||||
|
// wiped the rests and the rounds along with everything else, so the station the
|
||||||
|
// operator had just stopped was eligible again in the same second and the next
|
||||||
|
// period called it straight back.
|
||||||
|
func (a *App) HaltAutoCall() {
|
||||||
|
call := a.autoCallEngine().Halt()
|
||||||
|
if call != "" {
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acReason = fmt.Sprintf("%s stopped by the operator — set aside until auto-call is switched off and on", call)
|
||||||
|
a.acMu.Unlock()
|
||||||
|
applog.Printf("autocall: %s", a.acReason)
|
||||||
|
}
|
||||||
|
a.emitAutoCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoCallStatus is what the toolbar shows.
|
||||||
|
type AutoCallStatus struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// Only is the chase list, carried in the status so the field in the decodes
|
||||||
|
// toolbar and the one in Preferences are never two versions of the truth:
|
||||||
|
// whichever is typed into, both show it.
|
||||||
|
Only string `json:"only"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
// Waiting: what it would call if that station were not in a QSO.
|
||||||
|
Waiting string `json:"waiting"`
|
||||||
|
Calls int `json:"calls"`
|
||||||
|
Max int `json:"max"`
|
||||||
|
Misses int `json:"misses"`
|
||||||
|
MaxMiss int `json:"max_miss"`
|
||||||
|
Stopped bool `json:"stopped"`
|
||||||
|
// Greylisted counts the stations the operator has stopped this session, so
|
||||||
|
// the toolbar can say why a station on the air is never called.
|
||||||
|
Greylisted int `json:"greylisted"`
|
||||||
|
// Reason is the last decision in plain words. An auto-call that is doing
|
||||||
|
// nothing on purpose looks exactly like one that is broken.
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetAutoCallStatus() AutoCallStatus {
|
||||||
|
st := a.autoCallEngine().Status()
|
||||||
|
a.acMu.Lock()
|
||||||
|
reason := a.acReason
|
||||||
|
a.acMu.Unlock()
|
||||||
|
set := a.GetAutoCallSettings()
|
||||||
|
return AutoCallStatus{
|
||||||
|
Enabled: set.Enabled, Only: set.Only,
|
||||||
|
Target: st.Target, Waiting: st.Waiting, Calls: st.Attempts, Max: st.Max,
|
||||||
|
Misses: st.Misses, MaxMiss: st.MaxMiss, Stopped: st.Stopped,
|
||||||
|
Greylisted: a.autoCallEngine().Greylisted(),
|
||||||
|
Reason: reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) emitAutoCall() {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wruntime.EventsEmit(a.ctx, "autocall:status", a.GetAutoCallStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakeAutoCallTarget adopts the station the operator has just clicked, so a
|
||||||
|
// manual pick gets the same watchdogs as an automatic one — the click is the
|
||||||
|
// choice of station, not a decision to call it for ever.
|
||||||
|
func (a *App) TakeAutoCallTarget(call, band, mode string) {
|
||||||
|
if !a.GetAutoCallSettings().Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
call = strings.ToUpper(strings.TrimSpace(call))
|
||||||
|
if call == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.autoCallEngine().Take(autocall.Candidate{
|
||||||
|
Decode: autocall.Decode{Call: call, Band: band, Mode: mode, At: time.Now().UTC(), IsNew: true},
|
||||||
|
Need: a.autoCallNeed(call, band, mode),
|
||||||
|
Watched: a.autoCallWatched(call),
|
||||||
|
})
|
||||||
|
a.emitAutoCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The decode stream, cut into periods ───────────────────────────────────
|
||||||
|
|
||||||
|
// acDecode is one decode held until its period is complete.
|
||||||
|
type acDecode struct {
|
||||||
|
d autocall.Decode
|
||||||
|
tx bool // the decode is our own transmission echoed back
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallFeed takes one decode from the UDP loop.
|
||||||
|
//
|
||||||
|
// Decodes arrive one datagram at a time and a decision needs the whole period:
|
||||||
|
// the best station in it, and whether the target was there at all. They are
|
||||||
|
// therefore buffered under the period they belong to, and the period is judged
|
||||||
|
// when the next one starts — or, if the band goes quiet, by the sweeper below,
|
||||||
|
// which is what makes "not decoded for three of its periods" reachable when the
|
||||||
|
// answer is that nothing is being decoded at all.
|
||||||
|
func (a *App) autoCallFeed(d autocall.Decode) {
|
||||||
|
if !a.GetAutoCallSettings().Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inst := d.Instance
|
||||||
|
key := acPeriodKey(d.At, d.TRPeriod)
|
||||||
|
a.acMu.Lock()
|
||||||
|
if a.acPeriod == nil {
|
||||||
|
a.acPeriod, a.acAt, a.acTR, a.acBuf = map[string]string{}, map[string]time.Time{}, map[string]int{}, map[string][]acDecode{}
|
||||||
|
a.acFed = map[string]time.Time{}
|
||||||
|
}
|
||||||
|
if prev := a.acPeriod[inst]; prev != "" && prev != key {
|
||||||
|
prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst]
|
||||||
|
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
|
||||||
|
a.acBuf[inst] = []acDecode{{d: d}}
|
||||||
|
a.acMu.Unlock()
|
||||||
|
a.autoCallJudge(inst, prev, prevAt, prevTR, buf)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
|
||||||
|
a.acFed[inst] = time.Now()
|
||||||
|
a.acBuf[inst] = append(a.acBuf[inst], acDecode{d: d})
|
||||||
|
a.acMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// acQuiet is how long a period is left open after its LAST decode arrives.
|
||||||
|
//
|
||||||
|
// This is the whole timing budget of the feature. A decoder finishes a period
|
||||||
|
// and sends its decodes about a second before the next slot opens, so the
|
||||||
|
// answer has to be back before that boundary — a reply that arrives after it
|
||||||
|
// makes the decoder start its call several seconds into the slot, which is what
|
||||||
|
// an operator sees as "it calls late" and what a station on the other end sees
|
||||||
|
// as a message it cannot decode.
|
||||||
|
//
|
||||||
|
// It was a whole slot plus four seconds, measured from the DECODE'S OWN
|
||||||
|
// TIMESTAMP — the start of the period, not the moment it arrived — so the
|
||||||
|
// answer left about four seconds INTO the next slot, every time.
|
||||||
|
//
|
||||||
|
// 800 ms: long enough for a busy period's decodes to arrive together (measured
|
||||||
|
// in bursts of a few hundred milliseconds), short enough to answer inside the
|
||||||
|
// same second they landed.
|
||||||
|
const acQuiet = 800 * time.Millisecond
|
||||||
|
|
||||||
|
// autoCallSweep closes the periods nothing has closed for us. Called on a timer.
|
||||||
|
//
|
||||||
|
// Per receiver, because with two decoders one may fall silent while the other
|
||||||
|
// is busy — and it is the silent one's period that has to close for a missed
|
||||||
|
// period to be counted at all.
|
||||||
|
func (a *App) autoCallSweep() {
|
||||||
|
if !a.GetAutoCallSettings().Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type due struct {
|
||||||
|
inst, key string
|
||||||
|
at time.Time
|
||||||
|
tr int
|
||||||
|
buf []acDecode
|
||||||
|
}
|
||||||
|
var ready []due
|
||||||
|
a.acMu.Lock()
|
||||||
|
for inst, key := range a.acPeriod {
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tr := a.acTR[inst]
|
||||||
|
if tr <= 0 {
|
||||||
|
tr = 15
|
||||||
|
}
|
||||||
|
// Measured from when the last decode ARRIVED, not from the period it
|
||||||
|
// belongs to: a decode is stamped with the start of its own slot, so
|
||||||
|
// waiting "a slot plus four seconds" from that stamp is waiting until
|
||||||
|
// the middle of the NEXT slot. See acQuiet.
|
||||||
|
if time.Since(a.acFed[inst]) < acQuiet {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]})
|
||||||
|
delete(a.acPeriod, inst)
|
||||||
|
delete(a.acBuf, inst)
|
||||||
|
}
|
||||||
|
a.acMu.Unlock()
|
||||||
|
for _, d := range ready {
|
||||||
|
a.autoCallJudge(d.inst, d.key, d.at, d.tr, d.buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallSilence is the empty period. With the band dead, no decode ever
|
||||||
|
// arrives to close the next one, and the target's absence would never be
|
||||||
|
// counted — so a period with nothing in it is still a period.
|
||||||
|
//
|
||||||
|
// Only for the receiver the target is being called on: an idle second decoder
|
||||||
|
// has no periods to miss.
|
||||||
|
func (a *App) autoCallSilence() {
|
||||||
|
if !a.GetAutoCallSettings().Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inst, target := a.autoCallEngine().TargetInstance()
|
||||||
|
if target == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.acMu.Lock()
|
||||||
|
quiet := a.acPeriod[inst] == "" && time.Since(a.acLastJudge) > 20*time.Second
|
||||||
|
tr := a.acTR[inst]
|
||||||
|
a.acMu.Unlock()
|
||||||
|
if !quiet {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
a.autoCallJudge(inst, acPeriodKey(now, tr), now, tr, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func acPeriodKey(at time.Time, trSec int) string {
|
||||||
|
if trSec <= 0 {
|
||||||
|
trSec = 15
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", at.UTC().Unix()/int64(trSec))
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallJudge resolves what the log needs from each station in the period,
|
||||||
|
// runs the decision, and carries it out.
|
||||||
|
func (a *App) autoCallJudge(inst, key string, at time.Time, tr int, buf []acDecode) {
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acLastJudge = time.Now()
|
||||||
|
a.acMu.Unlock()
|
||||||
|
|
||||||
|
// One status call for the whole period. It reads a cached worked-index, but
|
||||||
|
// it is still per-callsign work and a busy period is thirty of them.
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var q []SpotQuery
|
||||||
|
var uniq []acDecode
|
||||||
|
for _, dd := range buf {
|
||||||
|
k := dd.d.Call + "|" + dd.d.Band + "|" + dd.d.Mode
|
||||||
|
if seen[k] {
|
||||||
|
// The same station twice in one period is one candidate, judged on
|
||||||
|
// its most callable line — the engine's bestOf does that, so both
|
||||||
|
// decodes are kept; only the status lookup is deduplicated.
|
||||||
|
uniq = append(uniq, dd)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[k] = true
|
||||||
|
uniq = append(uniq, dd)
|
||||||
|
q = append(q, SpotQuery{Call: dd.d.Call, Band: dd.d.Band, Mode: dd.d.Mode})
|
||||||
|
}
|
||||||
|
status := map[string]SpotStatus{}
|
||||||
|
for _, st := range a.ClusterSpotStatuses(q) {
|
||||||
|
status[st.Call+"|"+st.Band+"|"+st.Mode] = st
|
||||||
|
}
|
||||||
|
|
||||||
|
chase := a.autoCallChase()
|
||||||
|
cands := make([]autocall.Candidate, 0, len(uniq))
|
||||||
|
for _, dd := range uniq {
|
||||||
|
st := status[dd.d.Call+"|"+dd.d.Band+"|"+dd.d.Mode]
|
||||||
|
c := candidateOf(dd.d, st, chase)
|
||||||
|
c.Watched = a.autoCallWatched(dd.d.Call)
|
||||||
|
c.Hidden = a.autoCallHidden(dd.d.Call)
|
||||||
|
cands = append(cands, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := a.autoCallTX()
|
||||||
|
|
||||||
|
act := a.autoCallEngine().OnPeriod(autocall.Period{
|
||||||
|
Instance: inst, Key: key, At: at, TRPeriod: tr, Decodes: cands, TX: tx,
|
||||||
|
MyCall: a.opCall,
|
||||||
|
})
|
||||||
|
a.autoCallDo(act)
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidateOf turns one decode and the log's verdict on it into a candidate.
|
||||||
|
//
|
||||||
|
// Split out and kept pure because ONE line of it was wrong for weeks and
|
||||||
|
// nothing could catch it: the entity's verdict was read as the station's.
|
||||||
|
// chaseExtras is what the operator hunts BESIDES entities — the cluster's
|
||||||
|
// orthogonal markers, from the same switches that decide whether the badges are
|
||||||
|
// shown at all (Settings → DX Cluster). One answer for the eye and the
|
||||||
|
// transmitter: a marker withdrawn from the screen is not one to call for.
|
||||||
|
type chaseExtras struct{ pota, grid, pfx, county, state bool }
|
||||||
|
|
||||||
|
func candidateOf(d autocall.Decode, st SpotStatus, ch chaseExtras) autocall.Candidate {
|
||||||
|
c := autocall.Candidate{
|
||||||
|
Decode: d,
|
||||||
|
// The ENTITY's verdict decides what is still needed…
|
||||||
|
Need: autoCallNeedOf(st.Status),
|
||||||
|
// …and THIS CALLSIGN on this band and mode decides whether calling it
|
||||||
|
// would be a duplicate.
|
||||||
|
//
|
||||||
|
// Status was used for both. "worked" there means the COUNTRY is in the
|
||||||
|
// log on this band and mode, so on a band where the operator has most of
|
||||||
|
// them, nearly every station on the air was refused as already worked —
|
||||||
|
// a trace of one evening shows twenty decodes out of twenty-one turned
|
||||||
|
// away that way, the watched DXpedition among them.
|
||||||
|
Worked: st.WorkedSlot,
|
||||||
|
// The need exists only because a QSL never came: worth chasing, and worth
|
||||||
|
// less than the same need never worked at all.
|
||||||
|
Unconfirmed: st.UnconfStatus,
|
||||||
|
}
|
||||||
|
// NOTHING LEFT ON THE ENTITY, AND STILL WORTH A CALL.
|
||||||
|
//
|
||||||
|
// A prefix, a square, a county, a state, a park: never worked, orthogonal to
|
||||||
|
// the entity's verdict, and exactly what the operator ticked in the chase
|
||||||
|
// settings. They ranked at nothing-needed, so auto-call sat through a
|
||||||
|
// never-worked WPX prefix calling CQ and did not answer it.
|
||||||
|
//
|
||||||
|
// Only when the entity has nothing to add — a new band that is ALSO a new
|
||||||
|
// prefix is a new band, and says so.
|
||||||
|
if c.Need == autocall.NeedNone {
|
||||||
|
switch {
|
||||||
|
case ch.pfx && st.NewPfx:
|
||||||
|
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "prefix", st.UnconfPfx
|
||||||
|
case ch.county && st.NewCounty:
|
||||||
|
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "county", st.UnconfCty
|
||||||
|
case ch.state && st.NewState:
|
||||||
|
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "state", st.UnconfState
|
||||||
|
case ch.grid && st.NewGrid:
|
||||||
|
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "square", st.GridState == "unconf"
|
||||||
|
case ch.pota && st.NewPOTA:
|
||||||
|
c.Need, c.Extra = autocall.NeedExtra, "park"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallChase reads the chase switches the cluster and the decode list use.
|
||||||
|
//
|
||||||
|
// Read once per period rather than per decode: they are settings-store reads,
|
||||||
|
// and the period loop runs over every station on the band.
|
||||||
|
func (a *App) autoCallChase() chaseExtras {
|
||||||
|
on := func(key string) bool {
|
||||||
|
if a.settings == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
v, _ := a.settings.Get(a.ctx, "ui.opslog."+key)
|
||||||
|
return v != "0" // unset means on, as it does on the screen
|
||||||
|
}
|
||||||
|
return chaseExtras{
|
||||||
|
pota: on("chasePota"),
|
||||||
|
grid: on("chaseGrids"),
|
||||||
|
pfx: on("chasePfx"),
|
||||||
|
county: on("chaseCounty"),
|
||||||
|
state: on("chaseState"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallDo carries out a decision and records it.
|
||||||
|
func (a *App) autoCallDo(act autocall.Action) {
|
||||||
|
if act.Reason != "" {
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acReason = act.Reason
|
||||||
|
a.acMu.Unlock()
|
||||||
|
applog.Printf("autocall: %s", act.Reason)
|
||||||
|
}
|
||||||
|
switch act.Kind {
|
||||||
|
case autocall.DoReply:
|
||||||
|
d := act.Decode
|
||||||
|
if err := a.AnswerDecode(d.Instance, d.Ms, d.SNR, d.DT, d.AudioHz, d.ModeRaw, d.MsgRaw, d.LowConf); err != nil {
|
||||||
|
applog.Printf("autocall: the call to %s could not be sent: %v", d.Call, err)
|
||||||
|
}
|
||||||
|
case autocall.DoHalt:
|
||||||
|
// Soft: let the over finish, then stop transmitting. Hard: stop now.
|
||||||
|
// The engine decides — see Action.Soft.
|
||||||
|
if err := a.HaltDecodeTx("", act.Soft); err != nil {
|
||||||
|
applog.Printf("autocall: halt failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if act.Kind != autocall.DoNothing || act.Reason != "" {
|
||||||
|
a.emitAutoCall()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallNoteTX is the attempt counter, fed from the decoder's own status.
|
||||||
|
//
|
||||||
|
// ONCE PER TRANSMIT PERIOD. Status arrives every second and says "transmitting"
|
||||||
|
// throughout the over, so counting each one would spend the whole allowance of
|
||||||
|
// seven calls inside a single fifteen-second slot — the counter has to measure
|
||||||
|
// transmissions, not seconds of carrier.
|
||||||
|
func (a *App) autoCallNoteTX(tx autocall.TXState) {
|
||||||
|
if !a.GetAutoCallSettings().Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.acMu.Lock()
|
||||||
|
// Per receiver as well as per period: in a split view both decoders report
|
||||||
|
// their own transmissions, and one key for both would let the second one's
|
||||||
|
// carrier swallow the first one's count.
|
||||||
|
key := tx.Instance + "|" + acPeriodKey(time.Now().UTC(), a.acTR[tx.Instance])
|
||||||
|
if a.acTXPeriod == key {
|
||||||
|
a.acMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.acTXPeriod = key
|
||||||
|
a.acMu.Unlock()
|
||||||
|
a.autoCallDo(a.autoCallEngine().NoteTX(tx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallTX is the last transmit state reported, as the engine wants it.
|
||||||
|
func (a *App) autoCallTX() autocall.TXState {
|
||||||
|
a.acMu.Lock()
|
||||||
|
defer a.acMu.Unlock()
|
||||||
|
return a.acTX
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) autoCallSetTX(tx autocall.TXState) {
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acTX = tx
|
||||||
|
a.acMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAutoCallVisible is the decodes panel saying what it is SHOWING.
|
||||||
|
//
|
||||||
|
// The panel owns the filters and therefore owns the answer: reimplementing them
|
||||||
|
// here would give the screen and the transmitter two definitions of the same
|
||||||
|
// word, which is how they end up disagreeing. It sends the callsigns that
|
||||||
|
// survive its filters, and the engine calls nothing else.
|
||||||
|
//
|
||||||
|
// An empty list with active=false means "no filtering in force" — the panel was
|
||||||
|
// closed, or has never been opened this session — and the ladder decides alone.
|
||||||
|
func (a *App) SetAutoCallVisible(calls []string, active bool) {
|
||||||
|
set := make(map[string]bool, len(calls))
|
||||||
|
for _, c := range calls {
|
||||||
|
if c = strings.ToUpper(strings.TrimSpace(c)); c != "" {
|
||||||
|
set[c] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acVisible, a.acVisibleOn = set, active
|
||||||
|
a.acMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallHidden reports whether the panel's filters are keeping a station off
|
||||||
|
// the screen. Unknown when nothing is being published: not hidden.
|
||||||
|
func (a *App) autoCallHidden(call string) bool {
|
||||||
|
a.acMu.Lock()
|
||||||
|
defer a.acMu.Unlock()
|
||||||
|
if !a.acVisibleOn {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !a.acVisible[strings.ToUpper(strings.TrimSpace(call))]
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallNeedOf maps the cluster's own status vocabulary onto the ladder. One
|
||||||
|
// vocabulary for both, so a station that reads NEW BAND in the decodes list is
|
||||||
|
// the same NEW BAND the auto-call ranks — two answers to one question is how
|
||||||
|
// the panel and the caller quietly start disagreeing.
|
||||||
|
func autoCallNeedOf(status string) autocall.Need {
|
||||||
|
switch status {
|
||||||
|
case "new":
|
||||||
|
return autocall.NeedDXCC
|
||||||
|
case "new-band-mode", "new-band":
|
||||||
|
// New on both counts is at least a new band, and it is the better catch
|
||||||
|
// of the two — it must not fall below a plain new band.
|
||||||
|
return autocall.NeedBand
|
||||||
|
case "new-mode":
|
||||||
|
return autocall.NeedMode
|
||||||
|
case "new-slot":
|
||||||
|
return autocall.NeedSlot
|
||||||
|
}
|
||||||
|
return autocall.NeedNone
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) autoCallNeed(call, band, mode string) autocall.Need {
|
||||||
|
st := a.ClusterSpotStatuses([]SpotQuery{{Call: call, Band: band, Mode: mode}})
|
||||||
|
if len(st) == 0 {
|
||||||
|
return autocall.NeedNone
|
||||||
|
}
|
||||||
|
return autoCallNeedOf(st[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallWatched asks the watch list, which is the same list the spot alerts
|
||||||
|
// and the cluster colouring use.
|
||||||
|
func (a *App) autoCallWatched(call string) bool {
|
||||||
|
if a.watchlist == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := a.watchlist.Match(strings.ToUpper(strings.TrimSpace(call)))
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// startAutoCall starts the engine's loop, with auto-call OFF.
|
||||||
|
//
|
||||||
|
// It is never on from a stored value. This is the one setting in the program
|
||||||
|
// that puts the station on the air by itself, and the operator who left it on
|
||||||
|
// last night is not necessarily the one at the desk now — nor necessarily at
|
||||||
|
// the desk at all: OpsLog starts with Windows, and a rig that powers up with it
|
||||||
|
// would begin calling into an empty shack, on whatever band the radio happens
|
||||||
|
// to be on, hours after anybody decided that was a good idea.
|
||||||
|
//
|
||||||
|
// Arming it is one click, and it is a click somebody has to make.
|
||||||
|
func (a *App) startAutoCall() {
|
||||||
|
a.disarmAutoCall("launch")
|
||||||
|
go a.autoCallLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// disarmAutoCall switches auto-call off and writes that down.
|
||||||
|
//
|
||||||
|
// The setting is what the toolbar and the settings panel both read, so turning
|
||||||
|
// the engine off without storing it would show a lit switch over a silent
|
||||||
|
// transmitter — and the operator's next click, meaning "on", would send "off".
|
||||||
|
func (a *App) disarmAutoCall(why string) {
|
||||||
|
if a.settingOr(keyAutoCallOn, "0") == "1" {
|
||||||
|
applog.Printf("autocall: off at %s — it is never armed from a stored setting", why)
|
||||||
|
}
|
||||||
|
a.setSetting(keyAutoCallOn, "0")
|
||||||
|
a.applyAutoCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) autoCallLoop() {
|
||||||
|
// A quarter of a second. The sweeper is what closes a period, so its tick is
|
||||||
|
// part of the same budget as acQuiet: a two-second tick added up to two
|
||||||
|
// seconds of its own to every answer, which is most of the margin there is.
|
||||||
|
// The work per tick is a map read.
|
||||||
|
t := time.NewTicker(250 * time.Millisecond)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.autoCallSweep()
|
||||||
|
a.autoCallSilence()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/autocall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The entity's verdict is not the station's.
|
||||||
|
//
|
||||||
|
// From the air: on 10 m, where most countries are already in the log, the
|
||||||
|
// engine refused twenty decodes out of twenty-one as "worked" — a watched
|
||||||
|
// DXpedition calling CQ among them — because the ENTITY's status was read as
|
||||||
|
// the station's.
|
||||||
|
// allChased is the default: every orthogonal marker ticked.
|
||||||
|
var allChased = chaseExtras{pota: true, grid: true, pfx: true, county: true, state: true}
|
||||||
|
|
||||||
|
func TestCandidateWorkedIsTheCallsignNotTheEntity(t *testing.T) {
|
||||||
|
d := autocall.Decode{Call: "J38DX", Band: "10m", Mode: "FT8", IsNew: true}
|
||||||
|
|
||||||
|
// Grenada worked on 10 m FT8, this callsign never worked: nothing is needed
|
||||||
|
// from it, and calling it is NOT a duplicate.
|
||||||
|
c := candidateOf(d, SpotStatus{Status: "worked", WorkedSlot: false}, allChased)
|
||||||
|
if c.Worked {
|
||||||
|
t.Error("a station never worked was refused because its entity was")
|
||||||
|
}
|
||||||
|
if c.Need != autocall.NeedNone {
|
||||||
|
t.Errorf("need = %v on a worked entity, want none", c.Need)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same callsign already in the log on this band and mode IS a duplicate.
|
||||||
|
if c := candidateOf(d, SpotStatus{Status: "worked", WorkedSlot: true}, allChased); !c.Worked {
|
||||||
|
t.Error("a callsign already worked on this band and mode was not flagged")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a real need still carries through, with the unconfirmed distinction.
|
||||||
|
c = candidateOf(d, SpotStatus{Status: "new-band", UnconfStatus: true}, allChased)
|
||||||
|
if c.Need != autocall.NeedBand || !c.Unconfirmed {
|
||||||
|
t.Errorf("new-band unconfirmed came through as %v (unconf=%v)", c.Need, c.Unconfirmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A station whose ENTITY has nothing left to give can still be the reason the
|
||||||
|
// operator is on the band: a WPX prefix, a county, a square, a park that has
|
||||||
|
// never been worked. Reported from the air — auto-call sat through a
|
||||||
|
// never-worked prefix calling CQ and did nothing.
|
||||||
|
func TestOrthogonalMarkersAreWorthACall(t *testing.T) {
|
||||||
|
d := autocall.Decode{Call: "BH2SWB", Band: "10m", Mode: "FT8", IsNew: true}
|
||||||
|
worked := SpotStatus{Status: "worked"}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
what string
|
||||||
|
st SpotStatus
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"prefix", SpotStatus{Status: "worked", NewPfx: true}, "prefix"},
|
||||||
|
{"county", SpotStatus{Status: "worked", NewCounty: true}, "county"},
|
||||||
|
{"state", SpotStatus{Status: "worked", NewState: true}, "state"},
|
||||||
|
{"square", SpotStatus{Status: "worked", NewGrid: true}, "square"},
|
||||||
|
{"park", SpotStatus{Status: "worked", NewPOTA: true}, "park"},
|
||||||
|
} {
|
||||||
|
c := candidateOf(d, tc.st, allChased)
|
||||||
|
if c.Need != autocall.NeedExtra || c.Extra != tc.want {
|
||||||
|
t.Errorf("%s: need=%v extra=%q, want extra %q", tc.what, c.Need, c.Extra, tc.want)
|
||||||
|
}
|
||||||
|
// And not when the operator does not chase that kind of thing: the
|
||||||
|
// switch that withdraws the badge withdraws the call with it.
|
||||||
|
if c := candidateOf(d, tc.st, chaseExtras{}); c.Need != autocall.NeedNone {
|
||||||
|
t.Errorf("%s: called for a marker the operator does not chase", tc.what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// It is the LOWEST rung: a real need on the entity still says what it is.
|
||||||
|
if c := candidateOf(d, SpotStatus{Status: "new-band", NewPfx: true}, allChased); c.Need != autocall.NeedBand {
|
||||||
|
t.Errorf("need = %v — a new band that is also a new prefix is a new band", c.Need)
|
||||||
|
}
|
||||||
|
// Nothing anywhere is still nothing.
|
||||||
|
if c := candidateOf(d, worked, allChased); c.Need != autocall.NeedNone {
|
||||||
|
t.Errorf("need = %v on a station with nothing to gain", c.Need)
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-4
@@ -93,14 +93,27 @@ func keepKnownBands(want []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pskrMaxGrids bounds the receiver squares the broker is asked to filter on.
|
||||||
|
// Each is one subscription, and the traffic follows the area: 2000 km is about
|
||||||
|
// 615 squares, which the broker takes in batches without complaint. The cap is
|
||||||
|
// there so an absurd radius cannot turn into thousands of subscriptions — past
|
||||||
|
// it the nearest squares are kept, and the log says how many.
|
||||||
|
const pskrMaxGrids = 900
|
||||||
|
|
||||||
// bandOpenNearKm reads the stored receiver radius, falling back to the default
|
// bandOpenNearKm reads the stored receiver radius, falling back to the default
|
||||||
// for anything unset or out of range. The bounds are the two ways to make the
|
// for anything unset or out of range. The bounds are the two ways to make the
|
||||||
// watch useless: below 25 km almost nobody is ever near enough to hear anything,
|
// watch useless: below 25 km almost nobody is ever near enough to hear anything,
|
||||||
// and past 1000 km the reports stop being about the operator's own path — which
|
// and past 3000 km a "nearby" receiver is on the far side of a continent, which
|
||||||
// is the entire premise of measuring from a receiver rather than a transmitter.
|
// says nothing about the operator's own path.
|
||||||
|
//
|
||||||
|
// The ceiling was 1000 km, chosen with Europe in mind, where that radius holds a
|
||||||
|
// dozen countries' worth of receivers. It is the wrong number in VK: a station
|
||||||
|
// there can have almost nobody inside 300 km, and the chase list stayed empty
|
||||||
|
// not because nothing was on the air but because nothing was listening close
|
||||||
|
// enough to count.
|
||||||
func bandOpenNearKm(raw string) int {
|
func bandOpenNearKm(raw string) int {
|
||||||
n, err := strconv.Atoi(strings.TrimSpace(raw))
|
n, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||||
if err != nil || n < 25 || n > 1000 {
|
if err != nil || n < 25 || n > 3000 {
|
||||||
return pskr.DefaultNearKm
|
return pskr.DefaultNearKm
|
||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
@@ -194,7 +207,17 @@ func (a *App) startBandOpenFeed() {
|
|||||||
// hundred survived the NearKm test below. One ring of squares — about the
|
// hundred survived the NearKm test below. One ring of squares — about the
|
||||||
// same 300 km — is 0.2 to 1.2 a second, and the same for every operator,
|
// same 300 km — is 0.2 to 1.2 a second, and the same for every operator,
|
||||||
// where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K).
|
// where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K).
|
||||||
rxGrids := geo.NeighbourGrids(a.opLat, a.opLon, 1)
|
//
|
||||||
|
// The set follows the radius the operator asked for. It was one fixed ring
|
||||||
|
// whatever they set — about 300 km — so raising the radius bought nothing:
|
||||||
|
// the reports that would have satisfied it were never sent to us in the
|
||||||
|
// first place, and an operator in a thinly-populated region who widened the
|
||||||
|
// circle to find some receivers saw no change at all.
|
||||||
|
rxGrids := geo.GridsWithin(a.opLat, a.opLon, float64(s.NearKm), pskrMaxGrids)
|
||||||
|
if len(rxGrids) == 0 {
|
||||||
|
rxGrids = geo.NeighbourGrids(a.opLat, a.opLon, 1)
|
||||||
|
}
|
||||||
|
applog.Printf("pskr: receiver filter — %d squares within %d km of the station", len(rxGrids), s.NearKm)
|
||||||
|
|
||||||
var onGrid func(call, grid string)
|
var onGrid func(call, grid string)
|
||||||
if chaseGrids {
|
if chaseGrids {
|
||||||
|
|||||||
+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)."
|
||||||
@@ -33,6 +33,12 @@ func TestBulkEditFieldsAreWritable(t *testing.T) {
|
|||||||
id := m[1]
|
id := m[1]
|
||||||
// Handled before the column map: freq takes a numeric path (freq_hz +
|
// Handled before the column map: freq takes a numeric path (freq_hz +
|
||||||
// band together) and the extras live in extras_json.
|
// band together) and the extras live in extras_json.
|
||||||
|
// The integer My-station fields take their own numeric path
|
||||||
|
// (BulkSetIntField + bulkEditableIntCols in the repo), added after this
|
||||||
|
// guard was written — which is exactly the drift it exists to catch.
|
||||||
|
if id == "my_dxcc" || id == "my_cq_zone" || id == "my_itu_zone" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if id == "freq" || qso.IsBulkEditableExtra(id) {
|
if id == "freq" || qso.IsBulkEditableExtra(id) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+632
-2
@@ -1,9 +1,639 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.27.13",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"[NEW] Rotor widget redrawn: a night world map behind a square azimuth scale, an orange pointer following the mouse so a click lands where it is aimed, a yellow marker on the azimuth ordered until the antenna gets there, and quick turns in columns of six. The Ultrabeam boom and its second lobe are still drawn, and Station Control keeps the plain dial — dial only, since anything under it is pushed off the bottom of the row. Design contributed by EC1KD — thank you.",
|
||||||
|
"[NEW] Settings → Rotator now chooses the dial: the new world-map compass or the classic one. Both are kept — one reads across the shack, the other is the compact dial that was there before — and the choice applies to the docked widget and to Station Control at once.",
|
||||||
|
"Rotor widget: the Stop button no longer flickers on a rotor standing still. Movement was inferred from a one-degree change, which is less than the jitter a controller reports at rest.",
|
||||||
|
"[NEW] A docked watch-list panel, showing only what is ON THE AIR and still needed — one line per band and mode — callsign, band, mode, what it is worth, how long ago — freshest first, click to tune, with the cluster’s own NEW DXCC / NEW BAND / NEW SLOT badge on each row. The Watchlist tab is a tab, and an operator working FT8 lives on the decodes one: a station you asked to be told about was appearing on a screen you were not looking at. Turn it on with the bell in the toolbar.",
|
||||||
|
"[NEW] FT decodes: a distance column, next to the square it is computed from and in your own unit (km or miles). Rounded to whole units — a four-character grid is a square tens of kilometres wide and nothing finer is honest.",
|
||||||
|
"[NEW] The PSK Reporter panel switches to the station auto-call is WAITING for (the hourglass), while nothing is being called. Its analysis takes a history query and a period or two of live reports to fill, so starting it when the DX finally comes free is starting it too late — the wait is exactly what it should be spent on.",
|
||||||
|
"[NEW] Chase new: a band selection of its own, under the option (160 m to 70 cm). The station’s band list still applies underneath — this one says what is worth WATCHING tonight.",
|
||||||
|
"Chase new: the panel says what the feed is doing — up or down, how many reports it has taken, how many receiver squares it is subscribed to. An empty list said nothing about whether anything was arriving at all.",
|
||||||
|
"FT decodes: a receiver column appears when more than one decoder feeds the merged list, and the list empties for a receiver that changes band — half a screen of stations no longer reachable is worse than an empty one.",
|
||||||
|
"FT decodes: a pink WL badge marks a station on your watch list, and the period clock turns red while transmitting. It is the one thing on the screen that moves, so “am I on the air” is readable from across the room.",
|
||||||
|
"FT decodes: a message addressed to YOU is set in green, whole and bold, with a green edge on the row — read from the far side of the shack. The station you are calling keeps a light tint and its callsign picked out in red: most of what it sends goes to other people, and colouring those lines the same way said you were in a QSO you were not in.",
|
||||||
|
"Cluster: “superfox”, “super fox”, “fox/hound” and “F/H” in a spot comment are read as FT8. They are WSJT-X’s DXpedition transmit modes, not modes of their own, and the comment fell through to the band’s default.",
|
||||||
|
"Auto-call: what it calls, and in what order. It answers only what the decodes list is SHOWING — the filters above it (CQ only, LoTW only, the category chips, continents, minimum report, search) now steer the transmitter as well as the eye, and the separate “LoTW users only” option is gone. A new prefix, county, state, square or park is worth a call too, at the foot of the ladder and only for the kinds you chase (Settings → DX Cluster). A watched callsign outranks everything not on the list, a real need beats an unconfirmed one at the same level, and a better station may take over from one that has not answered yet — never from a QSO in progress.",
|
||||||
|
"Auto-call: it calls THROUGH a pileup. It used to give up the moment the station it was calling answered somebody else — which is precisely how a DX with a queue behaves, and the only way to be the next one is to keep calling while it works the others. Still bounded by the call and miss counters, and a station in mid-exchange is still never CHOSEN as a new target.",
|
||||||
|
"Auto-call: safety and control. It is always OFF at launch and after a profile switch, never armed from a stored setting — it is the one feature that puts the station on the air by itself, and OpsLog starts with Windows. Halt is now a verdict: the station is set aside for the session and never called again until auto-call is switched off and on. It says what it is waiting for, showing a wanted station that is working somebody else next to the Auto button. And it can log every decision (Settings → DXHunter): one line per period saying what was on the air and why each station was refused.",
|
||||||
|
"Auto-call: a round of fixes from on-air use. It no longer cuts your own 73 short, calls four to six seconds late, starts a call and drops it a second later, ignores a station calling you, misreads MSHV’s two-answers-in-one-line, refuses nearly everything on the air because it read the entity’s “worked” as the station’s, or opens with two misses already counted against a station it has only just picked.",
|
||||||
|
"Switching profile no longer leaves the previous logbook’s verdicts on the screen. Coming back from a profile with an empty log, every decode and spot stayed badged NEW until a restart: the worked-index, the chase-new list and the cached verdicts are now all dropped when the logbook changes.",
|
||||||
|
"Chase new fixes: the header counts both what the filters show and what the panel holds (“3 of 12 heard”), a stored filter set that hides everything is ignored, and switching between “new” and “new + unconfirmed” re-reads the list instead of leaving old verdicts on screen.",
|
||||||
|
"PSK Reporter panel fixes: the history is asked for in both feed scopes and refreshed every five minutes, so a target picked shortly after start-up no longer shows an empty page; the suggested transmit offset always has an answer when there is data; and the texts say ten minutes, which is the window actually used.",
|
||||||
|
"Callbook lookup: a compound callsign with a page of its OWN keeps that page’s location. HP/WE9G is filed on QRZ under exactly that form, with the Panama address and square the station is operating from, and OpsLog was throwing it away — the rule that drops a home address from a portable call was applying to pages that describe the operation itself. The record’s own country tells the two apart. Cached lookups heal on the next read; QSOs already logged without a grid keep it empty."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"[NEW] Widget rotor redessiné : carte du monde nocturne derrière une échelle d’azimut carrée, aiguille orange qui suit la souris pour qu’un clic parte où on vise, repère jaune sur l’azimut demandé jusqu’à l’arrivée de l’antenne, et directions rapides en colonnes de six. Le boom Ultrabeam et son deuxième lobe sont toujours tracés, et Station Control garde le cadran seul — rien en dessous, ce qui y était se retrouvait coupé en bas de la rangée. Design proposé par EC1KD — merci à lui.",
|
||||||
|
"[NEW] Réglages → Rotator permet de choisir le cadran : la nouvelle boussole carte du monde ou le cadran classique. Les deux sont conservés — l’un se lit de loin, l’autre est le cadran compact d’avant — et le choix s’applique aussitôt au widget docké comme à Station Control.",
|
||||||
|
"Widget rotor : le bouton Stop ne clignote plus sur un rotor à l’arrêt. Le mouvement était déduit d’un changement d’un degré, soit moins que le tremblement de lecture d’un contrôleur au repos.",
|
||||||
|
"[NEW] Un panneau watchlist docké, qui ne montre que ce qui est EN L’AIR et manque encore — une ligne par bande et mode — indicatif, bande, mode, ce que ça vaut, il y a combien de temps — le plus frais en haut, clic pour s’y caler, avec le badge NEW DXCC / NEW BAND / NEW SLOT du cluster sur chaque ligne. L’onglet Watchlist est un onglet, et en FT8 on vit sur celui des décodes : une station qu’on avait demandé à surveiller apparaissait sur un écran qu’on ne regardait pas. À activer avec la cloche dans la barre d’outils.",
|
||||||
|
"[NEW] FT decodes : une colonne distance, à côté du locator dont elle est calculée et dans votre unité (km ou miles). Arrondie à l’unité — un locator à quatre caractères est un carré de plusieurs dizaines de kilomètres, rien de plus fin ne serait honnête.",
|
||||||
|
"[NEW] Le panneau PSK Reporter bascule sur la station que l’auto-call ATTEND (le sablier), tant que rien n’est appelé. Son analyse met une requête d’historique et une période ou deux à se remplir : la lancer quand le DX se libère enfin, c’est la lancer trop tard — l’attente sert précisément à ça.",
|
||||||
|
"[NEW] Chase new : sélection de bandes propre au panneau, sous l’option (160 m à 70 cm). La liste de bandes de la station s’applique toujours en dessous — celle-ci dit ce qu’on veut SURVEILLER ce soir.",
|
||||||
|
"Chase new : le panneau indique l’état du flux — connecté ou non, nombre de rapports reçus, nombre de carrés de réception souscrits. Une liste vide ne disait rien sur ce qui arrivait vraiment.",
|
||||||
|
"FT decodes : une colonne récepteur apparaît quand plusieurs décodeurs alimentent la liste fusionnée, et la liste se vide pour un récepteur qui change de bande — un demi-écran de stations devenues inaccessibles est pire qu’un écran vide.",
|
||||||
|
"FT decodes : un badge WL rose marque une station de votre watchlist, et l’horloge de période passe au rouge en émission. C’est le seul élément qui bouge à l’écran, donc « suis-je en émission » se lit de loin.",
|
||||||
|
"FT decodes : un message qui VOUS est adressé s’affiche en vert, en entier et en gras, avec un liseré vert sur la ligne — lisible de l’autre bout du shack. La station que vous appelez garde une teinte légère et son indicatif en rouge : l’essentiel de ce qu’elle émet s’adresse à d’autres, et colorer ces lignes pareil laissait croire à un QSO en cours.",
|
||||||
|
"Cluster : « superfox », « super fox », « fox/hound » et « F/H » dans un commentaire de spot sont lus comme du FT8. Ce sont les modes d’émission DXpédition de WSJT-X, pas des modes en soi, et le commentaire retombait sur le mode par défaut de la bande.",
|
||||||
|
"Auto-call : ce qu’il appelle, et dans quel ordre. Il ne répond qu’à ce que la liste des décodes AFFICHE — les filtres du dessus (CQ seul, LoTW seul, les pastilles de catégorie, les continents, le rapport minimum, la recherche) pilotent désormais l’émission autant que l’œil, et l’option distincte « utilisateurs LoTW uniquement » disparaît. Un nouveau préfixe, comté, état, locator ou parc vaut aussi un appel, au dernier barreau de l’échelle et seulement pour ce que vous chassez (Réglages → DX Cluster). Un indicatif en watchlist passe devant tout ce qui n’y est pas, un vrai besoin devant un besoin non confirmé de même niveau, et une meilleure station peut prendre la place d’une autre qui n’a pas encore répondu — jamais celle d’un QSO en cours.",
|
||||||
|
"Auto-call : il appelle À TRAVERS un pile-up. Il abandonnait dès que la station appelée répondait à quelqu’un d’autre — or c’est exactement ce que fait un DX avec une file, et le seul moyen d’être le suivant est de continuer à appeler pendant qu’il travaille les autres. Toujours borné par les compteurs d’appels et de ratés, et une station en plein échange n’est toujours jamais CHOISIE comme nouvelle cible.",
|
||||||
|
"Auto-call : sécurité et contrôle. Il est toujours ARRÊTÉ au lancement et après un changement de profil, jamais armé depuis un réglage enregistré — c’est la seule fonction qui met la station en émission toute seule, et OpsLog démarre avec Windows. Halt devient un verdict : la station est mise de côté pour la session et n’est plus appelée jusqu’à ce que l’auto-call soit désactivé puis réactivé. Il dit ce qu’il attend, en affichant à côté du bouton Auto la station voulue qui travaille quelqu’un d’autre. Et il peut journaliser chaque décision (Réglages → DXHunter) : une ligne par période disant ce qui était sur l’air et pourquoi chaque station a été refusée.",
|
||||||
|
"Auto-call : série de corrections issues du trafic. Il ne coupe plus votre 73, n’appelle plus avec quatre à six secondes de retard, ne lance plus un appel pour le couper une seconde après, ne laisse plus sans réponse une station qui vous appelle, lit correctement les doubles réponses MSHV sur une même ligne, ne refuse plus presque tout ce qui passe parce qu’il prenait le « worked » de l’entité pour celui de la station, et ne démarre plus avec deux ratés au compteur sur une station tout juste choisie.",
|
||||||
|
"Changer de profil ne laisse plus les verdicts du carnet précédent à l’écran. En revenant d’un profil au log vide, tous les décodes et spots restaient marqués NEW jusqu’au redémarrage : l’index des contacts, la liste chase new et les verdicts en cache sont désormais vidés au changement de carnet.",
|
||||||
|
"Chase new, corrections : l’en-tête compte à la fois ce que les filtres montrent et ce que le panneau contient (« 3 sur 12 entendues »), un jeu de filtres enregistré qui cache tout est ignoré, et le passage entre « new » et « new + non confirmés » relit la liste au lieu de laisser d’anciens verdicts à l’écran.",
|
||||||
|
"Panneau PSK Reporter, corrections : l’historique est demandé dans les deux portées du flux et rafraîchi toutes les cinq minutes, donc une cible choisie peu après le démarrage n’affiche plus une page vide ; l’offset d’émission suggéré donne toujours une réponse quand il y a des données ; et les textes annoncent dix minutes, la fenêtre réellement utilisée.",
|
||||||
|
"Recherche callbook : un indicatif composé qui a sa PROPRE fiche garde la position de cette fiche. HP/WE9G est déposé sur QRZ sous cette forme exacte, avec l’adresse et le locator du Panama d’où la station émet, et OpsLog les jetait — la règle qui écarte l’adresse personnelle d’un indicatif portable s’appliquait aussi aux fiches qui décrivent l’opération elle-même. C’est le pays de la fiche qui les distingue. Les fiches en cache se corrigent à la lecture suivante ; les QSO déjà enregistrés sans locator le restent."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.12",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Help menu: “Join the Discord” opens the OpsLog Discord server in your browser.",
|
||||||
|
"Icom console: a tick box makes the band buttons recall the radio's own band stacking registers — where you last were on that band, in the mode you were in. Press the same band again to step through its three registers, exactly as the radio's band key does. A band the register cannot be read for still sends the fixed frequency.",
|
||||||
|
"Shared CAT: a frequency or mode just commanded is reported back at once instead of on the next poll, and a mode the radio is already in is no longer re-sent. WSJT-X waits for that readback before it believes the band changed — over a network CI-V link that wait was several seconds.",
|
||||||
|
"Chase new / band openings: the receiver radius goes up to 3000 km, and the PSK Reporter subscription now follows it. Widening the circle used to change nothing, because the reports it would have accepted were never sent to us. 300 km holds no receivers at all in much of VK, ZL or North America.",
|
||||||
|
"The radius is also reachable from the Chase new option itself, rather than only from inside the band-opening watch.",
|
||||||
|
"New PSK Reporter panel beside the FT decodes, which can be hidden: for the station you are calling, has he decoded YOU and how long ago, who near you he is hearing, who near him heard you, how many stations he is working through, and where his receive passband is free. Follows the station you click or call. Off by default — Settings → DXHunter/spots. The narrow feed is a few messages a second; a whole-band option is there for a fast machine.",
|
||||||
|
"FT Map: the arcs no longer run off the side of the map. The map shows one world, and a path crossing the antimeridian was drawn past 180° into the blank space beside it — from VK or ZL that is most of them, each one ending nowhere while its own marker sat on the far coast. A path now leaves one edge and re-enters at the other, at the latitude it left.",
|
||||||
|
"NEW — Auto-call: OpsLog can answer FT8/FT4 decodes on its own. It picks the best station on the air by what the log still needs — a watched callsign outranking the same need from anybody else — and, above all, it knows when to stop: 7 calls (15 for a watched callsign), 3 of the station’s own transmit periods with no decode of it, a four-minute backstop, and three series per station for a whole session. A station in the middle of a QSO with somebody else is never called, because it cannot answer. Every decision is written to the log with its reason, the toolbar button shows the target and the count as it goes, and Halt stops it. OFF by default — Settings → DXHunter/spots. It keys your transmitter without asking, so read that panel’s warning before switching it on, and switch DXHunter’s own auto-call off: two programs answering decodes from one shack transmit over each other.",
|
||||||
|
"PSK Reporter panel: the window is ten minutes, and the history query now fills BOTH directions when the target changes. Side by side with DXHunter on the same station at the same moment it showed 18 decodes against 27, and a co-area station missing — five minutes catches about one upload cycle per reporting station, and most of them report every five.",
|
||||||
|
"Auto-call: “Chase only” takes SEVERAL callsigns, separated by spaces or commas. Nothing off the list is called, the priority ladder still orders the ones on it, and working one leaves the others callable.",
|
||||||
|
"Two new themes on DXHunter’s palette: “DXHunter”, its slate and its blue exactly, and “DXHunter orange”, the same slate with OpsLog’s own orange accent. Both carry its status colours (emerald, amber, cyan, red), so a green number means the same thing in either window. Settings → General → Theme.",
|
||||||
|
"The auto-call chase list is also in the decodes toolbar, beside the Auto button: naming the station you are waiting for is done while watching the band, not in a settings tree. It is the same setting as the one in Preferences — type into either and both show it.",
|
||||||
|
"FT decodes: one click SELECTS a station — it fills the entry and points the panels at it, like a cluster spot — and a double click calls it. A single click used to hand the decode straight to WSJT-X as a reply, so brushing a row while reading the band started transmitting.",
|
||||||
|
"Auto-call with two decoders running: while a station is being called, a period from the other receiver is not looked at. It cannot start a second QSO over the one in progress however good the station it hears, its periods count no missed periods against a target that was never on its band, and its transmissions are not counted as calls to that target.",
|
||||||
|
"Watchlist: drawn as DXHunter draws it — the callsign in the interface font rather than monospaced (the difference that shows with the two windows side by side), badges at its size, a check or a warning triangle at the head of each spot line, and frequencies as 7.056 rather than 7.0560.",
|
||||||
|
"A decoder is named by what it IS, not by what it announces: Nexus sends its packets as “Tempo”, the engine inside it, and OpsLog showed a program the operator had never heard of. The id itself is untouched — it is what a reply, a halt and the auto-call are routed by.",
|
||||||
|
"WSJT-X colouring can grey out a station already worked on this band AND in this mode — the duplicate. Its own switch under the highlight option, off by default: the other three verdicts pick out a handful of decodes, this one can match most of a period on a well-filled log. Grey rather than a colour, because it says the opposite of the others.",
|
||||||
|
"Contest watchlist (Settings → DXHunter): beside the auto-add pattern there is now a list of callsigns, one per line. A pattern collects a fleet that shares a string — TM29WWA, HB9WWA, F4WWA/P — but not the station taking part under a callsign that says nothing about the event. Named in the list, it joins the contest watchlist the moment it is spotted."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Menu Aide : « Rejoindre le Discord » ouvre le serveur Discord d’OpsLog dans votre navigateur.",
|
||||||
|
"Console Icom : une case à cocher fait rappeler aux boutons de bande les registres de bande de la radio — là où vous étiez la dernière fois sur cette bande, dans le mode où vous étiez. Réappuyez sur la même bande pour parcourir ses trois registres, comme le fait la touche de bande du poste. Une bande dont le registre est illisible envoie toujours la fréquence fixe.",
|
||||||
|
"CAT partagé : une fréquence ou un mode qu’on vient de commander est renvoyé immédiatement, sans attendre le sondage suivant, et un mode que la radio a déjà n’est plus réémis. WSJT-X attend cette relecture avant de croire au changement de bande — sur une liaison CI-V réseau, cette attente durait plusieurs secondes.",
|
||||||
|
"Chase new / ouvertures : le rayon des récepteurs monte à 3000 km, et l’abonnement PSK Reporter le suit désormais. Élargir le cercle ne changeait rien, car les reports qu’il aurait acceptés ne nous étaient jamais envoyés. 300 km ne contient aucun récepteur dans une bonne partie de VK, ZL ou d’Amérique du Nord.",
|
||||||
|
"Ce rayon est aussi accessible depuis l’option Chase new elle-même, et non plus seulement depuis la veille d’ouvertures.",
|
||||||
|
"Nouveau panneau PSK Reporter à côté des décodages FTx, masquable : pour la station que vous appelez, vous a-t-il décodé et depuis combien de temps, qui entend-il près de chez vous, qui près de lui vous a entendu, combien de stations défilent chez lui, et où son passe-bande est libre. Il suit la station que vous cliquez ou appelez. Désactivé par défaut — Réglages → DXHunter/spots. Le flux étroit ne coûte que quelques messages par seconde ; une option « toute la bande » existe pour une machine rapide.",
|
||||||
|
"FT Map : les arcs ne partent plus hors de la carte. La carte n’affiche qu’un seul monde, et un chemin franchissant l’antiméridien était tracé au-delà de 180°, dans le vide à côté — depuis VK ou ZL c’est la majorité d’entre eux, chacun finissant nulle part alors que son propre marqueur était sur la côte opposée. Un chemin sort désormais par un bord et revient par l’autre, à la latitude où il est sorti.",
|
||||||
|
"NOUVEAU — Appel automatique : OpsLog peut répondre seul aux décodages FT8/FT4. Il choisit la meilleure station en fonction de ce qui manque au log — un indicatif surveillé passant devant le même besoin chez un autre — et surtout il sait s’arrêter : 7 appels (15 pour un indicatif surveillé), 3 périodes d’émission de la station sans la décoder, un butoir de quatre minutes, et trois séries par station pour toute une session. Une station en plein QSO avec quelqu’un d’autre n’est jamais appelée : elle ne peut pas répondre. Chaque décision est écrite dans le journal avec sa raison, le bouton de la barre affiche la cible et le décompte en direct, et Stop l’interrompt. DÉSACTIVÉ par défaut — Réglages → DXHunter/spots. Il met votre émetteur en marche sans vous demander : lisez l’avertissement du panneau avant de l’activer, et coupez l’appel automatique de DXHunter — deux programmes qui répondent aux décodages du même shack s’émettent dessus.",
|
||||||
|
"Panneau PSK Reporter : la fenêtre passe à dix minutes, et la requête d’historique remplit désormais les DEUX sens au changement de cible. Côte à côte avec DXHunter sur la même station au même instant, il affichait 18 décodages contre 27, et une station de la région manquait — cinq minutes ne captent qu’un cycle d’envoi par station, et la plupart n’envoient que toutes les cinq minutes.",
|
||||||
|
"Appel automatique : « Chasser uniquement » accepte PLUSIEURS indicatifs, séparés par des espaces ou des virgules. Rien hors de la liste n’est appelé, l’échelle de priorité départage ceux qui y sont, et en travailler un laisse les autres appelables.",
|
||||||
|
"Deux nouveaux thèmes sur la palette de DXHunter : « DXHunter », son ardoise et son bleu à l’identique, et « DXHunter orange », la même ardoise avec l’orange d’OpsLog. Les deux reprennent ses couleurs d’état (émeraude, ambre, cyan, rouge) : un nombre vert veut dire la même chose dans les deux fenêtres. Réglages → Général → Thème.",
|
||||||
|
"La liste de chasse de l’appel automatique est aussi dans la barre des décodages, à côté du bouton Auto : nommer la station qu’on attend se fait en regardant la bande, pas dans un arbre de réglages. C’est le même réglage que dans les Préférences — saisi dans l’un, il apparaît dans l’autre.",
|
||||||
|
"Décodages FT : un clic SÉLECTIONNE une station — il remplit la saisie et y pointe les panneaux, comme un spot du cluster — et un double-clic l’appelle. Un simple clic passait le décodage directement à WSJT-X en réponse : frôler une ligne en lisant la bande déclenchait une émission.",
|
||||||
|
"Appel automatique avec deux décodeurs : pendant qu’une station est appelée, une période de l’autre récepteur n’est pas examinée. Il ne peut pas ouvrir un second QSO par-dessus celui en cours, si bonne que soit la station qu’il entend ; ses périodes ne comptent aucune période manquée contre une cible qui n’a jamais été sur sa bande, et ses émissions ne comptent pas comme des appels vers elle.",
|
||||||
|
"Watchlist : dessinée comme DXHunter la dessine — l’indicatif dans la police de l’interface plutôt qu’en chasse fixe (la différence qui saute aux yeux avec les deux fenêtres côte à côte), pastilles à sa taille, coche ou triangle d’alerte en tête de chaque ligne de spot, et fréquences en 7.056 plutôt que 7.0560.",
|
||||||
|
"Un décodeur est nommé par ce qu’il EST, non par ce qu’il annonce : Nexus envoie ses paquets sous le nom « Tempo », le moteur qu’il embarque, et OpsLog affichait un programme inconnu de l’opérateur. L’identifiant lui-même n’est pas touché : c’est par lui que passent une réponse, un Stop et l’appel automatique.",
|
||||||
|
"Coloration WSJT-X : possibilité de griser une station déjà travaillée sur cette bande ET dans ce mode — le doublon. Sa propre case sous l’option de coloration, désactivée par défaut : les trois autres verdicts désignent quelques décodages, celui-ci peut concerner la majorité d’une période sur un log bien rempli. En gris et non en couleur, car il dit l’inverse des autres.",
|
||||||
|
"Watchlist contest (Réglages → DXHunter) : à côté du motif d’ajout automatique, une liste d’indicatifs, un par ligne. Un motif attrape une flotte qui partage une chaîne — TM29WWA, HB9WWA, F4WWA/P — mais pas la station engagée sous un indicatif qui ne dit rien de l’événement. Nommée dans la liste, elle rejoint la watchlist contest dès qu’elle est spottée."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.11",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Country resolution with the ClubLog file enabled: retired prefixes are recognised again. cty.dat describes the world as it is TODAY — Niue moved to E6, so ZK2 reverted to New Zealand there, and every ZK2 contact ever made was silently relabelled New Zealand, taking a confirmed entity out of the operator’s DXCC with it. ZK1 lost the Cook Islands the same way. ClubLog’s prefix table still knows both and is now consulted whenever no per-callsign exception applies, instead of only for callsigns that happened to have one. It never overrules an exact “=CALLSIGN” entry in cty.dat, and where it has no answer cty.dat still decides. Reprocess an affected import with Update from ClubLog.",
|
||||||
|
"CQ and ITU zones now come from the callbook when it has them. cty.dat carries ONE representative pair per entity, and OpsLog was stamping it over QRZ.com’s per-station answer — so every Asiatic Russia contact was logged CQ 17 / ITU 30, whatever the operator’s real zone (RU0LL is 19/34, UA0SDX 18/32). That is a WAZ credit for a zone never worked. The entity still comes from cty.dat, which is the authority on what a callsign IS; a zone says where the station SITS, and only the callbook knows that within a country eight CQ zones wide. Where the callbook is silent, cty.dat fills as before.",
|
||||||
|
"Callsign cache: a lookup is now stored as the callbook returned it, and the country file is applied when it is read. A value OpsLog derived can no longer come back later looking like something the page said — which is how the wrong zones outlived their fix — and a country-file update now reaches rows already cached.",
|
||||||
|
"“Send to Cloudlog / Wavelog” joins the right-click upload menu. It was the one configured service missing from it: Cloudlog keeps no per-QSO sent status — deliberately, since it dedupes server-side — and the menu had been built around that status. An explicit selection needs no status to be safe, which is exactly why the absence stops mattering here.",
|
||||||
|
"HAMLOG.online uploads are closed. The site no longer issues API keys and its upload API takes nothing else, so the auto-upload switch and the “Send to” entry armed something that could only fail. Everything else stays: their confirmations still import from a file (which never needed a key), and the sent/received state already in your log remains readable, filterable and bulk-editable. The upload code is kept whole against the day keys come back.",
|
||||||
|
"Yaesu console: the power slider no longer springs back to 100 W on a 200 W radio. The console already knew an FTDX101MP could do 200 — that is what it drew the slider to — but the command that sets it was clamped to 100, so the rig was politely given half of what was asked for and the next poll showed it. FTDX101MP, FT-DX5000 and FTDX9000 reach 200 W now, and a rig reporting more than expected is still believed.",
|
||||||
|
"Chase new: clicking a row now does what clicking a cluster spot does — including telling WSJT-X, JTDX or MSHV to change mode, so picking an FT4 station while the decoder sits in FT8 actually moves it. It also brings across the mode and its RST preset, and any park or summit reference. It used to do a hand-picked half of that, which left the one thing the window exists for — jumping onto a station — decoding the wrong mode.",
|
||||||
|
"FT decodes: JTDX on FT4 no longer reads as Q65. A decode carries a one-character mode marker, and the two programs disagree about “:” — Q65 in WSJT-X, FT4 in JTDX — so the character alone cannot answer, and the wrong answer poisoned every verdict behind it: new mode, new slot, the mode filter. The sending program’s own status names the mode in full and now settles it; the markers both forks agree on are untouched."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Résolution des entités avec le fichier ClubLog activé : les préfixes retirés sont de nouveau reconnus. cty.dat décrit le monde tel qu’il est AUJOURD’HUI — Niue est passée en E6, donc ZK2 y est revenu à la Nouvelle-Zélande, et tous les contacts ZK2 jamais faits étaient silencieusement réétiquetés Nouvelle-Zélande, emportant une entité confirmée hors du DXCC de l’opérateur. ZK1 perdait les Cook du Sud de la même façon. La table de préfixes ClubLog connaît toujours les deux : elle est désormais consultée dès qu’aucune exception par indicatif ne s’applique, et non plus seulement pour les indicatifs qui en avaient une. Elle ne prime jamais sur une entrée exacte « =INDICATIF » de cty.dat, et là où elle n’a pas de réponse c’est cty.dat qui tranche. Repassez un import concerné par « Mettre à jour depuis ClubLog ».",
|
||||||
|
"Les zones CQ et ITU proviennent désormais du callbook quand il les connaît. cty.dat ne porte QU’UNE paire représentative par entité, et OpsLog l’imposait par-dessus la réponse par station de QRZ.com — tout contact avec la Russie asiatique était donc enregistré en CQ 17 / ITU 30, quelle que soit la zone réelle (RU0LL est en 19/34, UA0SDX en 18/32). C’est un crédit WAZ pour une zone jamais travaillée. L’entité vient toujours de cty.dat, qui fait autorité sur ce qu’un indicatif EST ; une zone dit où la station SE TROUVE, et seul le callbook le sait dans un pays large de huit zones CQ. Là où le callbook se tait, cty.dat comble comme avant.",
|
||||||
|
"Cache des indicatifs : une recherche est désormais stockée telle que le callbook l’a rendue, le fichier pays étant appliqué à la lecture. Une valeur déduite par OpsLog ne peut plus revenir plus tard avec l’apparence de ce qu’a dit la page — c’est ainsi que les mauvaises zones survivaient à leur correctif — et une mise à jour du fichier pays atteint maintenant les fiches déjà en cache.",
|
||||||
|
"« Envoyer vers Cloudlog / Wavelog » rejoint le menu d’envoi du clic droit. C’était le seul service configuré qui y manquait : Cloudlog ne conserve aucun statut d’envoi par QSO — volontairement, puisqu’il dédoublonne côté serveur — et le menu était construit autour de ce statut. Une sélection explicite n’a besoin d’aucun statut pour être sûre : c’est précisément pourquoi cette absence cesse de compter ici.",
|
||||||
|
"Les envois vers HAMLOG.online sont fermés. Le site ne délivre plus de clé API et son interface d’envoi n’accepte rien d’autre : la case d’envoi automatique et l’entrée « Envoyer vers » armaient donc quelque chose qui ne pouvait qu’échouer. Tout le reste demeure : leurs confirmations s’importent toujours depuis un fichier (ce qui n’a jamais demandé de clé), et l’état envoyé/reçu déjà présent dans votre log reste lisible, filtrable et modifiable en masse. Le code d’envoi est conservé intact pour le jour où les clés reviendraient.",
|
||||||
|
"Console Yaesu : le curseur de puissance ne revient plus à 100 W sur une radio de 200 W. La console savait déjà qu’un FTDX101MP peut sortir 200 — c’est à cela qu’elle dimensionnait son curseur — mais la commande d’envoi était bornée à 100 : le poste recevait donc poliment la moitié de ce qu’on lui demandait, et le sondage suivant l’affichait. FTDX101MP, FT-DX5000 et FTDX9000 atteignent désormais 200 W, et une radio qui annonce plus que prévu reste crue sur parole.",
|
||||||
|
"Chase new : cliquer une ligne fait désormais ce que fait un clic sur un spot du cluster — y compris demander à WSJT-X, JTDX ou MSHV de changer de mode, si bien que choisir une station FT4 alors que le décodeur est en FT8 l’y amène vraiment. Le mode et son RST par défaut suivent aussi, de même que toute référence de parc ou de sommet. La fenêtre n’en faisait qu’une moitié choisie à la main, ce qui laissait la seule chose pour laquelle elle existe — sauter sur une station — décoder dans le mauvais mode.",
|
||||||
|
"FT decodes : JTDX en FT4 ne s’affiche plus en Q65. Un décodage porte un marqueur de mode d’un seul caractère, et les deux logiciels ne s’accordent pas sur « : » — Q65 pour WSJT-X, FT4 pour JTDX : le caractère seul ne peut donc pas trancher, et sa mauvaise réponse contaminait tout ce qui en découle — nouveau mode, nouveau slot, filtre de mode. Le statut envoyé par le logiciel lui-même nomme le mode en entier et tranche désormais ; les marqueurs sur lesquels les deux s’accordent ne changent pas."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.10",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Widget order (Settings → Appearance): the row to the right of the entry can be rearranged by dragging — the whole row is the handle, and a line shows where it will land. QSO entry and the F1-F5 panel head the list, locked — they are not part of that row and nothing should be allowed to push what you type into behind a rotator dial. A widget you have switched off keeps its place and comes back where you left it, and the main view follows as you drag.",
|
||||||
|
"Rotator, GS-232 over a serial port: the port is opened once and kept, instead of being reopened for every command. An Arduino-based controller — K3NG’s firmware, the ERC family — RESETS when its serial port is opened, so OpsLog was rebooting it several times a second and every command landed in the bootloader: a controller that answered a terminal perfectly reported “no reply to C” here. A freshly opened port is now left to boot before the first command, stale bytes from a previous exchange are discarded, and a failed exchange releases the port so the next one starts clean."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Ordre des widgets (Réglages → Apparence) : la rangée à droite de la saisie se réorganise par glisser-déposer — toute la ligne se saisit, et un trait montre où elle atterrira. La saisie du QSO et le panneau F1-F5 ouvrent la liste, verrouillés — ils ne font pas partie de cette rangée, et rien ne doit pouvoir repousser ce dans quoi vous tapez derrière une boussole de rotor. Un widget désactivé garde sa place et revient là où vous l’aviez laissé, et la vue principale suit pendant que vous glissez.",
|
||||||
|
"Rotor, GS-232 sur port série : le port est ouvert une fois et conservé, au lieu d’être rouvert à chaque commande. Un contrôleur à base d’Arduino — le firmware K3NG, la famille ERC — REDÉMARRE à l’ouverture de son port série : OpsLog le redémarrait donc plusieurs fois par seconde et chaque commande tombait dans le bootloader. Un contrôleur qui répondait parfaitement à un terminal annonçait ici « no reply to C ». Un port fraîchement ouvert a désormais le temps de démarrer avant la première commande, les octets résiduels d’un échange précédent sont écartés, et un échange en échec libère le port pour que le suivant reparte propre."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.9",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"FT decodes warn when the decoding application announces a band the radio is not on — the signature of a lost CAT link, where it repeats the last frequency it knew and every decode after that carries a stale band. Nothing downstream could tell, so NEW BAND was being judged against a band the operator had left. OpsLog says it rather than deciding: a second receiver on another band is a real setup, and it costs that one only a line to read past.",
|
||||||
|
"Voice keyer with CAT keying: an option saying the keyer’s audio arrives on the radio’s DATA / USB input rather than the microphone socket. A Kenwood TS-590 has two transmit commands — TX opens the front mic, TX1 the rear ACC2/USB — so a keyer playing through the rig’s own sound card was transmitting dead air while the radio listened to a microphone nobody was speaking into. Shown on the Kenwood backend only — no other radio family draws the distinction — and the Test PTT button exercises the same path."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les FT decodes signalent quand le logiciel de décodage annonce une bande sur laquelle la radio n’est pas — la signature d’une liaison CAT perdue, où il répète la dernière fréquence connue et où tous les décodages suivants portent une bande périmée. Rien en aval ne pouvait s’en apercevoir : NOUVELLE BANDE était donc jugé sur une bande quittée. OpsLog le dit sans décider à votre place : un second récepteur sur une autre bande est une configuration légitime, et il ne lui en coûte qu’une ligne à ignorer.",
|
||||||
|
"Voice keyer avec PTT CAT : une option indiquant que l’audio du keyer arrive sur l’entrée DATA / USB de la radio et non sur la prise micro. Un Kenwood TS-590 a deux commandes d’émission — TX ouvre le micro de face avant, TX1 l’ACC2/USB — si bien qu’un keyer jouant par la carte son du poste émettait dans le vide pendant que la radio écoutait un micro devant lequel personne ne parlait. Affichée sur le backend Kenwood uniquement — aucune autre famille de postes ne fait cette distinction — et le bouton Test PTT emprunte le même chemin."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.8",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"The band matrix’s DIG row is now a rotation: click it and it answers for FT8, then FT4, then each digital mode your mode list holds — in YOUR order — then back to DIG. One row per digital mode would be the honest layout and there is no height for it beside the other widgets, so the row keeps its place and changes what it says. The label column is sized once for the longest mode it can show, so the matrix never shifts as the rotation comes round to RTTY.",
|
||||||
|
"Icom console: LSB and USB are separate buttons and can finally be commanded by name — the single SSB button resolved the sideband from the band, so there was no way to ask an IC-7300 for USB on 40 m. A rig reporting the folded “SSB” still lights the side its frequency implies.",
|
||||||
|
"Icom console: a 60 m band button, and the antenna and PSK controls only appear on radios that have them. An IC-7300 has one antenna socket and no native PSK mode, so ANT1/ANT2 could only ever disagree with its front panel and the PSK button was dead furniture.",
|
||||||
|
"Icom console: the mic gain is no longer hidden outside phone modes — on USB-D it still sets what the radio transmits at, and an operator who lives in FT8 had none at all.",
|
||||||
|
"Icom CI-V: address 0x00 can be chosen. It was silently refused and replaced by the IC-7610 default, with no way to say what was meant."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"La ligne DIG de la matrice devient une rotation : un clic et elle répond pour FT8, puis FT4, puis chaque mode numérique de votre liste — dans VOTRE ordre — puis retour à DIG. Une ligne par mode numérique serait la mise en page honnête et la hauteur manque à côté des autres widgets : la ligne garde donc sa place et change ce qu’elle dit. La colonne des libellés est dimensionnée une fois pour le plus long mode qu’elle peut afficher : la matrice ne bouge donc plus quand la rotation arrive sur RTTY.",
|
||||||
|
"Console Icom : LSB et USB sont deux boutons distincts et peuvent enfin être demandés par leur nom — le bouton SSB unique déduisait la bande latérale de la fréquence, impossible donc de demander l’USB à un IC-7300 sur 40 m. Une radio qui annonce le « SSB » générique allume malgré tout le côté que sa fréquence implique.",
|
||||||
|
"Console Icom : un bouton de bande 60 m, et les commandes antenne et PSK n’apparaissent que sur les radios qui en disposent. Un IC-7300 n’a qu’une prise d’antenne et pas de mode PSK natif : ANT1/ANT2 ne pouvait que contredire sa face avant, et le bouton PSK était un meuble mort.",
|
||||||
|
"Console Icom : le gain micro n’est plus masqué hors des modes phonie — en USB-D il règle toujours le niveau d’émission, et un opérateur qui vit en FT8 n’en avait aucun.",
|
||||||
|
"CI-V Icom : l’adresse 0x00 peut être choisie. Elle était refusée en silence et remplacée par le défaut IC-7610, sans moyen de dire ce que l’on voulait."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.7",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"DX Cluster: a disconnected server keeps its pill, so it can be reconnected — disconnecting one used to make it vanish along with the only way back.",
|
||||||
|
"HamQTH: an “Upload the whole log” button in the QSL Manager — one file instead of one request per QSO, so a first sync takes seconds rather than the better part of an hour. It REPLACES the log held on HamQTH (the site has no partial upload), so it asks first, is scoped to the callsign this profile uploads as, and compresses a large log to stay under the 20 MB limit.",
|
||||||
|
"Outbound ADIF (forwarding a logged QSO to another logger such as Log4OM): the receive side is filled in when the contact was not split, so BAND_RX and FREQ_RX are present. The importer already did this; the logging paths did not, so what a QSO carried depended on which door it came in through.",
|
||||||
|
"HamQTH joins the places the other services already were: two Recent-QSOs columns (sent status and date), the QSO filter, and bulk edit — the last one so a log uploaded to HamQTH by hand can be marked as sent instead of being offered for upload all over again.",
|
||||||
|
"HamQTH whole-log upload: it reports itself in the console like every other action — the callsign it is scoped to, how many of the logbook’s QSOs that leaves, the file size, and HamQTH’s own reply — and says plainly that HamQTH imports the file in the background and e-mails any ADIF errors, so a site count that lags or stops short is explained rather than mysterious.",
|
||||||
|
"QSO editor: correcting a frequency now moves its band with it, TX and RX — a QSO fixed to 7.1 MHz no longer stays filed on 20m. The band is only touched when the frequency lands in a known allocation, so a half-typed number never blanks it.",
|
||||||
|
"CAT: an option to put the radio in USB for digital modes (Settings → CAT), for every backend. Clicking an FT8 spot on a rig whose CAT layer resolves “digital” to RTTY/FSK — OmniRig does, per rig file — landed the operator in FSK, which cannot pass FT8 at all. The QSO is still logged as FT8: only the radio changes.",
|
||||||
|
"Fixed: a “worked but not confirmed” badge turned solid again after the next QSO, so an entity worked on a band still read NEW BAND as if it had never been worked there. Four hand-written copies of the same status mapping had drifted apart — the two that re-fetch dropped the unconfirmed flags, and none of them ever carried the grid one. There is one mapping now.",
|
||||||
|
"The Chase switches (POTA, US counties, prefixes, grids) now hold in FT decodes and in Chase new, not only in the DX Cluster — they say what you hunt, not which panel is open. Unchecked, their badges and filter chips disappear from all three and only the entity verdicts remain: NEW DXCC, band, mode, slot.",
|
||||||
|
"New DXHunter page in Preferences: every Chase setting moves there from the DX Cluster page — the hunt, its confirmation sources, and the POTA / SOTA / US counties / prefixes / grids switches. They govern three screens now, so they no longer live under the name of one of them; more of DXHunter’s ideas will land beside them.",
|
||||||
|
"Chase US states joins the other switches: unchecked, the NEW STATE badge and its filter chip go quiet everywhere.",
|
||||||
|
"FT Map: zooming no longer leaves a dead strip along the bottom. Leaflet measures its container once, when the map is created — which here is the instant the tab is selected, before the layout has settled — and it is now told whenever that box changes size.",
|
||||||
|
"FT decodes: the continent filter shows all seven, always, instead of only those currently on the feed — the row no longer reshuffles under the pointer when the first Asian station decodes, and it says what the filter can do before anything has been heard.",
|
||||||
|
"Panadapter spots now carry SmartSDR’s priority: an entity never worked comes first, then the band / mode / slot needs, then POTA, SOTA, county and prefix, and everything else last. The radio stacks spots that sit close in frequency behind a “+” and draws only one — it picks by priority, so a new DXCC was disappearing behind stations already in the log simply because OpsLog never said which was worth the space.",
|
||||||
|
"A station already worked on the SAME band and mode no longer advertises a need. Working it a second time cannot turn a missing QSL into a confirmation, so the badge goes quiet on that callsign — and stays on every other station of the entity, which is where the need can actually be answered."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"DX Cluster : un serveur déconnecté garde sa pastille et peut donc être reconnecté — le déconnecter le faisait disparaître avec le seul moyen d’y revenir.",
|
||||||
|
"HamQTH : un bouton « Envoyer tout le log » dans le QSL Manager — un seul fichier au lieu d’une requête par QSO, une première synchro passe de près d’une heure à quelques secondes. Il REMPLACE le log stocké sur HamQTH (le site n’a pas d’envoi partiel) : il demande donc confirmation, se limite à l’indicatif du profil et compresse un gros log pour rester sous la limite de 20 Mo.",
|
||||||
|
"ADIF sortant (transfert d’un QSO vers un autre log, Log4OM par exemple) : le côté réception est renseigné quand le contact n’était pas en split, donc BAND_RX et FREQ_RX sont présents. L’import le faisait déjà, pas les chemins de log — ce qu’un QSO transportait dépendait donc de la porte par laquelle il était entré.",
|
||||||
|
"HamQTH rejoint les endroits où les autres services étaient déjà : deux colonnes dans les QSO récents (statut et date d’envoi), le filtre de QSO et l’édition groupée — cette dernière pour qu’un log envoyé à la main sur HamQTH puisse être marqué comme envoyé au lieu d’être reproposé à l’envoi.",
|
||||||
|
"Envoi du log complet HamQTH : il rend compte dans la console comme toutes les autres actions — l’indicatif retenu, combien de QSO du journal cela représente, la taille du fichier et la réponse de HamQTH — et indique clairement que HamQTH importe le fichier en arrière-plan et envoie les erreurs ADIF par e-mail : un compteur en retard ou incomplet sur le site est ainsi expliqué au lieu d’être mystérieux.",
|
||||||
|
"Éditeur de QSO : corriger une fréquence déplace désormais sa bande avec elle, TX comme RX — un QSO corrigé à 7,1 MHz ne reste plus classé en 20m. La bande n’est touchée que si la fréquence tombe dans une allocation connue : un nombre à moitié tapé ne l’efface jamais.",
|
||||||
|
"CAT : une option pour mettre la radio en USB sur les modes numériques (Réglages → CAT), pour tous les backends. Cliquer un spot FT8 sur un poste dont la couche CAT traduit « numérique » par RTTY/FSK — c’est le cas d’OmniRig, selon le fichier radio — faisait basculer l’opérateur en FSK, incapable de passer du FT8. Le QSO reste enregistré en FT8 : seule la radio change.",
|
||||||
|
"Corrigé : un badge « contacté mais non confirmé » redevenait plein dès le QSO suivant, si bien qu’une entité contactée sur une bande affichait NEW BAND comme si elle ne l’avait jamais été. Quatre copies écrites à la main de la même conversion de statut avaient divergé — les deux qui rafraîchissent perdaient les drapeaux « non confirmé », et aucune ne transportait celui des locators. Il n’y en a plus qu’une.",
|
||||||
|
"Les cases Chasse (POTA, comtés US, préfixes, locators) s’appliquent désormais aux FT decodes et à Chase new, plus seulement au DX Cluster — elles disent ce que vous chassez, pas quel panneau est ouvert. Décochées, leurs badges et leurs puces de filtre disparaissent des trois écrans et il ne reste que les verdicts d’entité : NOUVEAU DXCC, bande, mode, slot.",
|
||||||
|
"Nouvelle page DXHunter dans les Préférences : tous les réglages de chasse y déménagent depuis la page DX Cluster — le mode de chasse, ses sources de confirmation et les cases POTA / SOTA / comtés US / préfixes / locators. Ils commandent trois écrans, ils ne vivent donc plus sous le nom d’un seul ; d’autres idées de DXHunter viendront s’y ajouter.",
|
||||||
|
"Chasser les états US rejoint les autres cases : décochée, le badge NOUVEL ÉTAT et sa puce de filtre se taisent partout.",
|
||||||
|
"FT Map : le zoom ne laisse plus une bande morte en bas. Leaflet mesure son conteneur une seule fois, à la création de la carte — ici l’instant où l’onglet est sélectionné, avant que la mise en page ne se soit stabilisée — et il est désormais prévenu à chaque changement de taille.",
|
||||||
|
"FT decodes : le filtre continent affiche les sept, toujours, au lieu des seuls présents dans le flux — la rangée ne se réorganise plus sous le pointeur quand la première station asiatique décode, et elle annonce ce que le filtre sait faire avant même d’avoir entendu quoi que ce soit.",
|
||||||
|
"Les spots du panadapter portent désormais la priorité SmartSDR : une entité jamais contactée d’abord, puis les besoins bande / mode / slot, puis POTA, SOTA, comté et préfixe, et le reste en dernier. La radio empile les spots proches en fréquence derrière un « + » et n’en dessine qu’un — elle choisit par priorité, si bien qu’un nouveau DXCC disparaissait derrière des stations déjà au log, faute pour OpsLog d’avoir dit laquelle méritait la place.",
|
||||||
|
"Une station déjà contactée sur la MÊME bande et le même mode n’annonce plus de besoin. La recontacter ne transformera pas une QSL manquante en confirmation : le badge se tait sur cet indicatif — et reste sur toutes les autres stations de l’entité, là où le besoin peut réellement être comblé."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.6",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Switching the settings database no longer shows “OpsLog is already running”: the automatic relaunch now waits for the closing instance to release its lock instead of racing it.",
|
||||||
|
"HamQTH upload: 8th external service — real-time QSO upload to the HamQTH online logbook with your callbook credentials (auto-upload on log, “Send to…” right-click, QSL Manager backlog upload, connection test).",
|
||||||
|
"Fixed: the right-click “Send to HAMLOG.online” was uploading the selection to QRZ.com with the QRZ key — it now goes to HAMLOG.online.",
|
||||||
|
"LoTW: TQSL no longer refuses non-US stations over MY_CNTY — the field is stripped before signing unless it is the US “XX,County” shape LoTW actually validates (a Canadian “ONTARIO,Kawartha” was rejecting the whole record). Exports also stop gluing a full state name onto the county.",
|
||||||
|
"DX Cluster: two new chase switches — Chase US counties and Chase new prefixes — and unchecking Chase new grids now also withdraws the NEW GRID badge. Each switch removes its badge from the spots AND its chip from the status filters, like Chase POTA always did.",
|
||||||
|
"Confirmations: a HamQTH row — sent only, defaulting to R (to upload), since HamQTH publishes no confirmations to receive. Setting such a default no longer disables the auto-upload it was meant to arm (it also affected HAMLOG.online).",
|
||||||
|
"New DXpeditions tab (Tools): the announced operations from NG3K’s ADXO next to the DX-World news feed. Every announcement is judged against YOUR log and carries one badge — NEW DXCC, NEW BAND, NEW SLOT… — with an “only what I need” filter, and one click adds its callsigns to the watchlist. The news headlines carry the same Watch button, over the callsigns mined out of their titles.",
|
||||||
|
"Watchlist: adding or removing a callsign now raises the same kind of notification as a new version, instead of a message inside the watchlist page — a call can be added from the cluster or the DXpeditions tab, where that message was never seen.",
|
||||||
|
"QSO editor, QSL Info: a HamQTH channel and its row in the status table — sent only, the received column showing a dash since the site publishes no confirmations.",
|
||||||
|
"QSO editor, QSL Info: the confirmation channels are listed paper QSL and LoTW first — the two that carry an ARRL award — then alphabetically, in both the picker and the status table.",
|
||||||
|
"Band map: a chevron in the footer folds the colour legend away and brings it back — four lines of a short screen, remembered between sessions.",
|
||||||
|
"Band map: ctrl+wheel no longer zooms it — that gesture is the window zoom everywhere else in OpsLog. The + and − buttons keep the zoom.",
|
||||||
|
"DX Cluster: the server pills drop the CONNECTED/DISCONNECTED word — the colour already said it — and clicking one now connects or disconnects that server on its own, without opening Settings. State, retries, address and last error moved into the tooltip."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Changer de base de réglages n’affiche plus « OpsLog is already running » : la relance automatique attend désormais que l’instance qui se ferme libère son verrou au lieu de la prendre de vitesse.",
|
||||||
|
"Upload HamQTH : 8e service externe — envoi des QSO en temps réel vers le logbook HamQTH avec vos identifiants du lookup (upload auto au log, « Envoyer vers… » au clic droit, rattrapage via le QSL Manager, test de connexion).",
|
||||||
|
"Corrigé : le clic droit « Envoyer vers HAMLOG.online » envoyait la sélection à QRZ.com avec la clé QRZ — elle part maintenant vers HAMLOG.online.",
|
||||||
|
"LoTW : TQSL ne refuse plus les stations hors US à cause de MY_CNTY — le champ est retiré avant signature sauf s’il a la forme US « XX,County » que LoTW valide réellement (un « ONTARIO,Kawartha » canadien rejetait tout l’enregistrement). L’export cesse aussi de coller un nom d’état complet devant le comté.",
|
||||||
|
"DX Cluster : deux nouvelles cases — Chasser les comtés US et Chasser les nouveaux préfixes — et décocher Chasser les nouveaux locators retire désormais aussi le badge NEW GRID. Chaque case enlève son badge des spots ET sa puce des filtres de statut, comme Chase POTA le faisait déjà.",
|
||||||
|
"Confirmations : une ligne HamQTH — envoi seulement, à R (à envoyer) par défaut, HamQTH ne publiant aucune confirmation à recevoir. Définir un tel défaut ne désactive plus l’upload automatique qu’il était censé armer (cela touchait aussi HAMLOG.online).",
|
||||||
|
"Nouvel onglet DXpéditions (Outils) : les opérations annoncées par l’ADXO de NG3K à côté du fil d’actualités DX-World. Chaque annonce est jugée sur VOTRE log et porte un badge — NOUVEAU DXCC, NOUVELLE BANDE, NOUVEAU SLOT… — avec un filtre « seulement ce qu’il me manque », et un clic ajoute ses indicatifs à la watchlist. Les actualités portent le même bouton Surveiller, sur les indicatifs extraits de leurs titres.",
|
||||||
|
"Watchlist : ajouter ou retirer un indicatif déclenche désormais une notification du même type que celle des nouvelles versions, au lieu d’un message dans la page watchlist — un indicatif peut être ajouté depuis le cluster ou l’onglet DXpéditions, où ce message n’était jamais vu.",
|
||||||
|
"Éditeur de QSO, onglet QSL : un canal HamQTH et sa ligne dans le tableau des statuts — envoi seulement, la colonne reçu affichant un tiret puisque le site ne publie aucune confirmation.",
|
||||||
|
"Éditeur de QSO, onglet QSL : les canaux de confirmation sont classés QSL papier puis LoTW — les deux qui comptent pour un diplôme ARRL — puis par ordre alphabétique, dans le sélecteur comme dans le tableau.",
|
||||||
|
"Band map : un chevron dans le pied de page replie la légende des couleurs et la fait revenir — quatre lignes gagnées sur un petit écran, mémorisé d’une session à l’autre.",
|
||||||
|
"Band map : ctrl+molette ne zoome plus la carte — ce geste est le zoom de la fenêtre partout ailleurs dans OpsLog. Les boutons + et − gardent le zoom.",
|
||||||
|
"DX Cluster : les pastilles de serveur perdent le mot CONNECTED/DISCONNECTED — la couleur le disait déjà — et cliquer sur l’une connecte ou déconnecte ce serveur seul, sans passer par les réglages. État, tentatives, adresse et dernière erreur passent dans l’infobulle."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.5",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"QSL Manager: Club Log confirmations — downloads your log matches (getmatches API) and stamps two new columns, ClubLog match status and match date, available everywhere: table columns, filters, bulk edit and the QSO editor.",
|
||||||
|
"Super Check Partial: option to merge Club Log’s weekly call list (~180k calls heard on the air in the last 3 years) with MASTER.SCP.",
|
||||||
|
"Fixed: opening the app could silently stop at startup (settings showing as default, “db not initialized”) when a database migration targeted a table that database no longer holds — migrations now skip what does not apply, and a startup failure is written to the log.",
|
||||||
|
"FT Map / Grid squares: the map no longer floats above the menus and the Preferences dialog.",
|
||||||
|
"KPA500: saving ANY settings page no longer power-cycles the amplifier. A save rebuilt every amplifier connection, and closing the COM port drops DTR/RTS — the KPA500’s power switch. An unchanged amplifier now keeps its running connection across saves.",
|
||||||
|
"Column picker: columns are listed alphabetically inside each group.",
|
||||||
|
"Confirmations: a “Club Log received” default for new QSOs, set to No out of the box — the match download flips it to Y."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"QSL Manager : confirmations Club Log — télécharge vos matches de log (API getmatches) et remplit deux nouvelles colonnes, statut et date de match ClubLog, disponibles partout : colonnes du tableau, filtres, édition groupée et éditeur de QSO.",
|
||||||
|
"Super Check Partial : option pour fusionner la liste hebdomadaire de Club Log (~180k indicatifs entendus sur l’air ces 3 dernières années) avec MASTER.SCP.",
|
||||||
|
"Corrigé : l’application pouvait se figer silencieusement au démarrage (réglages par défaut, « db not initialized ») quand une migration visait une table absente de cette base — les migrations ignorent désormais ce qui ne s’applique pas, et un échec de démarrage est écrit dans le log.",
|
||||||
|
"FT Map / Grid squares : la carte ne passe plus au-dessus des menus et de la fenêtre Préférences.",
|
||||||
|
"KPA500 : sauvegarder n’importe quelle page des réglages n’éteint plus l’ampli. Une sauvegarde reconstruisait chaque connexion d’ampli, et fermer le port COM relâche DTR/RTS — l’interrupteur d’alimentation du KPA500. Un ampli inchangé garde désormais sa connexion à travers les sauvegardes.",
|
||||||
|
"Sélecteur de colonnes : les colonnes sont triées alphabétiquement dans chaque groupe.",
|
||||||
|
"Confirmations : un défaut « Club Log reçu » pour les nouveaux QSO, à Non par défaut — le téléchargement des matches le passe à Y."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.4",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"FT decodes: a State column between the locator and the country — the two-letter badge plus the full name — for the WAS chasers.",
|
||||||
|
"FT decodes: a NEW STATE badge and filter — a US state never worked lights up in the status column, and the filter chip shows only those. The WAS chase is complete.",
|
||||||
|
"The hunt goes global: a “Chase” setting (DX Cluster page) decides for EVERY category — DXCC, band, mode, slot, prefix, county, state, grid — whether “worked but never confirmed” still counts as something to chase, judged against the confirmation sources you pick (LoTW, QSL card, eQSL, QRZ.com). Such needs show as dimmed badges: a QSL to chase, not a QSO to make. The grid’s own Chase selector folds into it.",
|
||||||
|
"New FTx menu gathering FT Decodes, the new FT Map and the Grid squares map.",
|
||||||
|
"FT Map: a world map of the live FTx decodes — great-circle arcs from your QTH to every station heard in the last 30 minutes, coloured by band, with the PSK-Reporter palette and a basemap picker.",
|
||||||
|
"Maps: one single world (no more side-by-side copies), the surround follows the theme colour, and zooming stays centred — on the FT Map and the Grid squares map.",
|
||||||
|
"Watchlist: fixed columns in the spot rows, so band, mode and frequency line up instead of drifting with the country name."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"FT decodes : une colonne État entre le locator et le pays — le badge deux lettres plus le nom complet — pour les chasseurs de WAS.",
|
||||||
|
"FT decodes : un badge et un filtre NOUVEL ÉTAT — un état US jamais contacté s’allume dans la colonne statut, et la puce de filtre ne montre que ceux-là. La chasse WAS est complète.",
|
||||||
|
"La chasse devient globale : un réglage « Chasse » (page DX Cluster) décide pour TOUTES les catégories — DXCC, bande, mode, slot, préfixe, comté, état, grille — si « contacté mais jamais confirmé » reste à chasser, jugé selon les sources de confirmation choisies (LoTW, carte QSL, eQSL, QRZ.com). Ces besoins s’affichent en badges atténués : une QSL à chasser, pas un QSO à faire. Le sélecteur Chasse des grilles fusionne dedans.",
|
||||||
|
"Nouveau menu FTx regroupant FT Decodes, la nouvelle FT Map et la carte Grid squares.",
|
||||||
|
"FT Map : une carte du monde des décodages FTx en direct — arcs orthodromiques depuis votre QTH vers chaque station entendue dans les 30 dernières minutes, colorés par bande, avec la palette PSK Reporter et un choix de fond de carte.",
|
||||||
|
"Cartes : un seul monde (fini les copies côte à côte), le pourtour suit la couleur du thème et le zoom reste centré — sur la FT Map et la carte Grid squares.",
|
||||||
|
"Watchlist : colonnes fixes dans les lignes de spots — bande, mode et fréquence s'alignent au lieu de dériver avec le nom du pays."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.26.13",
|
||||||
"date": "",
|
"date": "",
|
||||||
"en": [],
|
"en": [
|
||||||
"fr": []
|
"Elecraft console: MOX unkeys again, and the power and SWR bars move while transmitting. The poll stops while the carrier is up — the rig refuses most questions then — and the panel was left believing the radio was still receiving, so pressing MOX a second time sent another transmit command instead of stopping.",
|
||||||
|
"Elecraft console: RIT and XIT can be moved — ±10 and ±100 Hz buttons with the offset shown beside them. A lit RIT button says the feature is on and nothing about where it has put the receiver.",
|
||||||
|
"Elecraft console: a 4.0 kHz filter button, the K3 maximum and the one FT8 wants.",
|
||||||
|
"FT decodes: a station that is new on both the band and the mode now shows both badges. That status had no badge of its own, so those rows passed the BAND and MODE filters and then displayed no reason for being in the list — only their grid or prefix badge, which looked like the filter leaking.",
|
||||||
|
"Two copies of MSHV are now told apart. MSHV calls every instance 'MSHV', and the dial frequency was filed under that name alone — so a second copy on another band overwrote the first, and decodes made on 14.095 came out labelled 2190 m. Instances are identified by their sending socket as well as their name, and a second one is shown as 'MSHV #2'."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Console Elecraft : MOX repasse bien en réception, et les barres de puissance et de ROS bougent pendant l'émission. L'interrogation s'arrête quand la porteuse est levée — la radio refuse alors la plupart des questions — et le panneau croyait donc la radio toujours en réception : un second appui sur MOX envoyait une nouvelle commande d'émission au lieu d'arrêter.",
|
||||||
|
"Console Elecraft : le RIT et le XIT se règlent — boutons ±10 et ±100 Hz avec le décalage affiché à côté. Un bouton RIT allumé dit que la fonction est active et rien sur l'endroit où elle a mis le récepteur.",
|
||||||
|
"Console Elecraft : un bouton de filtre à 4,0 kHz, le maximum du K3 et celui qu'il faut pour le FT8.",
|
||||||
|
"Décodes FT : une station nouvelle à la fois sur la bande et dans le mode affiche maintenant les deux badges. Ce statut n'avait pas de badge à lui : ces lignes passaient les filtres BANDE et MODE puis n'affichaient aucune raison d'être là — seulement leur badge de locator ou de préfixe, ce qui donnait l'impression d'un filtre qui fuyait.",
|
||||||
|
"Deux copies de MSHV sont maintenant distinguées. MSHV nomme chaque instance « MSHV », et la fréquence d'affichage était rangée sous ce seul nom : une seconde copie sur une autre bande écrasait la première, et des décodes faits sur 14.095 ressortaient étiquetés 2190 m. Les instances sont désormais identifiées par leur socket autant que par leur nom, et la seconde s'affiche « MSHV #2 »."
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"version": "0.26.12",
|
"version": "0.26.12",
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/clublog"
|
||||||
|
"hamlog/internal/dxcc"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reported from a real import: every ZK2 contact came back as New Zealand and a
|
||||||
|
// confirmed entity — Niue — disappeared from the operator's DXCC.
|
||||||
|
//
|
||||||
|
// cty.dat is not wrong, it is CURRENT: Niue moved to E6, so ZK2 reverted to New
|
||||||
|
// Zealand there. ClubLog still knows ZK2 was Niue, and knowing that is the whole
|
||||||
|
// reason for enabling its country file.
|
||||||
|
func TestClublogPrefixRescuesRetiredPrefixes(t *testing.T) {
|
||||||
|
dir := filepath.Join("build", "bin", "data")
|
||||||
|
dm := dxcc.NewManager(dir)
|
||||||
|
if err := dm.LoadFromDisk(); err != nil {
|
||||||
|
t.Skipf("cty.dat not available here: %v", err)
|
||||||
|
}
|
||||||
|
cm := clublog.NewManager("", dir)
|
||||||
|
if err := cm.EnsureLoaded(); err != nil {
|
||||||
|
t.Skipf("ClubLog country file not available here: %v", err)
|
||||||
|
}
|
||||||
|
a := &App{ctx: context.Background(), dxcc: dm, clublog: cm}
|
||||||
|
|
||||||
|
when := time.Date(2005, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
cases := []struct {
|
||||||
|
call string
|
||||||
|
want int // ADIF entity
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
{"ZK2KK", 188, "Niue — the reported case"},
|
||||||
|
{"ZK1XYZ", 234, "South Cook Islands, lost the same way"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
q := qso.QSO{Callsign: c.call, QSODate: when}
|
||||||
|
if !a.applyClublogException(&q, true) {
|
||||||
|
t.Errorf("%s: ClubLog changed nothing (%s)", c.call, c.why)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q.DXCC == nil || *q.DXCC != c.want {
|
||||||
|
got := 0
|
||||||
|
if q.DXCC != nil {
|
||||||
|
got = *q.DXCC
|
||||||
|
}
|
||||||
|
t.Errorf("%s resolved to %d (%s), want %d — %s", c.call, got, q.Country, c.want, c.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A callsign ClubLog's prefix table does not know must be left to cty.dat
|
||||||
|
// rather than blanked: silence is not an answer.
|
||||||
|
q := qso.QSO{Callsign: "E6AG", QSODate: when}
|
||||||
|
if a.applyClublogException(&q, true) && q.DXCC != nil && *q.DXCC != 188 {
|
||||||
|
t.Errorf("E6AG was moved off Niue, to %d", *q.DXCC)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Confirmations default of "R" means "I intend to upload this", not "it has
|
||||||
|
// gone" — reading it as sent would silently disable the auto-upload the default
|
||||||
|
// was set to arm. Only a real stamp blocks.
|
||||||
|
func TestExtrasSaysSent(t *testing.T) {
|
||||||
|
pending := []string{"", " ", "R", "r", "N", "Q", "I"}
|
||||||
|
for _, v := range pending {
|
||||||
|
if extrasSaysSent(v) {
|
||||||
|
t.Errorf("extrasSaysSent(%q) = true, want false (still to upload)", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sent := []string{"Y", "y", "20260831"} // "Y" today, a date in older builds
|
||||||
|
for _, v := range sent {
|
||||||
|
if !extrasSaysSent(v) {
|
||||||
|
t.Errorf("extrasSaysSent(%q) = false, want true (already uploaded)", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The HamQTH default lands on the extras key the uploader reads, and must leave
|
||||||
|
// the QSO eligible.
|
||||||
|
func TestHamQTHDefaultStaysUploadable(t *testing.T) {
|
||||||
|
q := &qso.QSO{Callsign: "F4BPO"}
|
||||||
|
applyQSLDefaultsTo(q, defaultQSLDefaults())
|
||||||
|
if got := q.Extras[hamqthSentKey]; got != "R" {
|
||||||
|
t.Fatalf("HamQTH sent extra = %q, want %q", got, "R")
|
||||||
|
}
|
||||||
|
if extrasSaysSent(q.Extras[hamqthSentKey]) {
|
||||||
|
t.Error("a freshly logged QSO reads as already uploaded to HamQTH")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
704fe1bf370b669665df0606fae8a69d
|
f9b41e192918fa2511f68cd1b361fcd3
|
||||||
+983
-318
File diff suppressed because it is too large
Load Diff
@@ -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 }) {
|
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).
|
// 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 isSPE = !!amp.spe;
|
||||||
const isACOM = !!amp.acom;
|
const isACOM = !!amp.acom;
|
||||||
|
const isKPA = !!amp.kpa;
|
||||||
|
|
||||||
if (isSPE) {
|
if (isSPE) {
|
||||||
const spe = amp.spe;
|
const spe = amp.spe;
|
||||||
@@ -127,7 +128,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
if (isACOM) {
|
if (isACOM) {
|
||||||
const acom = amp.acom;
|
const acom = amp.acom;
|
||||||
return (
|
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">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<button type="button" disabled={!acom.connected}
|
<button type="button" disabled={!acom.connected}
|
||||||
onClick={() => AmpOperate(amp.id, !acom.operate).catch(() => {})}
|
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.
|
// PowerGenius XL — OPERATE + meters ride on the Flex; fan mode on the GSCP link.
|
||||||
const pg = amp.pgxl || {};
|
const pg = amp.pgxl || {};
|
||||||
const viaFlex = !!flex?.amp_available;
|
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
|
// With several amplifiers configured the caller passes a selection ("all" or an
|
||||||
// amp id) chosen from the toolbar icon's dropdown.
|
// 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.
|
// Full-scale watts for the output bar, from the model string.
|
||||||
function maxW(model?: string, fallback = 1300): number {
|
function maxW(model?: string, fallback = 1300): number {
|
||||||
@@ -92,27 +92,39 @@ function OperateButton({ operate, disabled, onClick, t }: {
|
|||||||
function AmpBlock({ amp, flex, showName, t }: {
|
function AmpBlock({ amp, flex, showName, t }: {
|
||||||
amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string;
|
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();
|
const hold = usePeakHold();
|
||||||
|
|
||||||
if (spe || acom) {
|
// One block for the three amplifiers OpsLog drives over its own link. They
|
||||||
const s = spe || acom;
|
// 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
|
// 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,
|
// 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).
|
// gets there first (the amp is polled on its own slower cycle).
|
||||||
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false;
|
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 w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing);
|
||||||
const swr = Number(spe ? s.swr_ant : s.swr) || 0;
|
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
|
// 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.
|
// 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 (
|
return (
|
||||||
<div className="h-full flex flex-col gap-1.5">
|
<div className="h-full flex flex-col gap-1.5">
|
||||||
{showName && (
|
{showName && (
|
||||||
<div className="flex items-center gap-1.5">
|
<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={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>}
|
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
|
||||||
</div>
|
</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>
|
<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">
|
<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>
|
</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} />
|
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { EventsEmit } from '../../wailsjs/runtime/runtime';
|
||||||
|
import { GripVertical, Lock } from 'lucide-react';
|
||||||
import { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
|
import { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -127,6 +129,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-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" />
|
||||||
<span className="inline-block w-7 h-5 rounded bg-mx-none ring-2 ring-mx-cur ring-inset" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<button type="button" onClick={reset}
|
<button type="button" onClick={reset}
|
||||||
@@ -284,6 +292,116 @@ export function AppearancePanel() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<MatrixColorsSection />
|
<MatrixColorsSection />
|
||||||
|
<WidgetOrderSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The row of widgets to the right of the entry, in the order they appear.
|
||||||
|
//
|
||||||
|
// Flexbox does the moving in the main view — this list only decides the order
|
||||||
|
// property each one gets. That is why a widget switched OFF still holds its
|
||||||
|
// place here: it comes back where the operator left it rather than at the end.
|
||||||
|
export const WIDGET_KEYS = [
|
||||||
|
'livestations', 'chat', 'rotor', 'motorant', 'antgenius',
|
||||||
|
'amp', 'tuner', 'scp', 'chasenew', 'watchlist', 'dvk', 'winkeyer', 'photo',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const WIDGET_LABELS: Record<string, string> = {
|
||||||
|
livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor',
|
||||||
|
motorant: 'wo.motorant', antgenius: 'wo.antgenius', amp: 'wo.amp',
|
||||||
|
tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew', watchlist: 'wo.watchlist',
|
||||||
|
dvk: 'wo.dvk', winkeyer: 'wo.winkeyer', photo: 'wo.photo',
|
||||||
|
};
|
||||||
|
|
||||||
|
function readWidgetOrder(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('opslog.widgetOrder');
|
||||||
|
const arr = raw ? JSON.parse(raw) : null;
|
||||||
|
if (Array.isArray(arr)) {
|
||||||
|
// A key from an older build that no longer exists is dropped; a widget
|
||||||
|
// added since joins the end. An old preference can never hide a new one.
|
||||||
|
const known = arr.filter((k: any) => (WIDGET_KEYS as readonly string[]).includes(k));
|
||||||
|
return [...known, ...WIDGET_KEYS.filter((k) => !known.includes(k))];
|
||||||
|
}
|
||||||
|
} catch { /* corrupt pref → the default order */ }
|
||||||
|
return [...WIDGET_KEYS];
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetOrderSection() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [order, setOrder] = useState<string[]>(readWidgetOrder);
|
||||||
|
const dragKey = useRef<string | null>(null);
|
||||||
|
const [dragging, setDragging] = useState<string | null>(null);
|
||||||
|
// Where the row would land. Drawn as a line above the target rather than by
|
||||||
|
// colouring it: the question a dragging hand asks is "between which two", and
|
||||||
|
// a highlighted row answers a different one.
|
||||||
|
const [over, setOver] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const commit = (keys: string[]) => {
|
||||||
|
setOrder(keys);
|
||||||
|
try { localStorage.setItem('opslog.widgetOrder', JSON.stringify(keys)); } catch { /* private mode */ }
|
||||||
|
// The main view listens: an order is meant to be watched as it is dragged,
|
||||||
|
// not discovered after closing Preferences.
|
||||||
|
EventsEmit('widgets:order', keys);
|
||||||
|
};
|
||||||
|
const moveTo = (from: string, to: string) => {
|
||||||
|
if (from === to) return;
|
||||||
|
const next = order.filter((k) => k !== from);
|
||||||
|
const at = next.indexOf(to);
|
||||||
|
next.splice(at < 0 ? next.length : at, 0, from);
|
||||||
|
commit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold">{t('wo.title')}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('wo.hint')}</p>
|
||||||
|
<div className="space-y-1 max-w-md">
|
||||||
|
{/* The two that cannot move, shown so the order reads as the whole row
|
||||||
|
rather than as a list that mysteriously starts at the third item. */}
|
||||||
|
{['wo.entry', 'wo.details'].map((k) => (
|
||||||
|
<div key={k}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2 py-1.5 text-sm text-muted-foreground">
|
||||||
|
<Lock className="size-3.5 shrink-0 opacity-60" />
|
||||||
|
<span className="flex-1 min-w-0 truncate">{t(k)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{order.map((k) => (
|
||||||
|
// The WHOLE row is the handle, not the grip alone: a list whose rows
|
||||||
|
// can only be moved by a 16-pixel icon is a list most people conclude
|
||||||
|
// cannot be moved. The grip stays as the sign that it can.
|
||||||
|
<div key={k} draggable
|
||||||
|
onDragStart={(e) => { dragKey.current = k; setDragging(k); e.dataTransfer.effectAllowed = 'move'; }}
|
||||||
|
onDragEnd={() => { dragKey.current = null; setDragging(null); setOver(null); }}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!dragKey.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
if (over !== k) setOver(k);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => { if (over === k) setOver(null); }}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (!dragKey.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
moveTo(dragKey.current, k);
|
||||||
|
setOver(null);
|
||||||
|
}}
|
||||||
|
title={t('wo.drag')}
|
||||||
|
className={cn('flex items-center gap-2 rounded-md border bg-card px-2 py-1.5 text-sm select-none',
|
||||||
|
'cursor-grab active:cursor-grabbing transition-shadow',
|
||||||
|
dragging === k ? 'opacity-50 border-primary shadow-lg' : 'border-border hover:border-foreground/30',
|
||||||
|
// The landing line, on the edge the row would take.
|
||||||
|
over === k && dragging !== k && 'shadow-[inset_0_3px_0_0_var(--primary)]')}>
|
||||||
|
<GripVertical className="size-4 shrink-0 text-muted-foreground/50" />
|
||||||
|
<span className="flex-1 min-w-0 truncate">{t(WIDGET_LABELS[k] ?? k)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => commit([...WIDGET_KEYS])}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground underline">
|
||||||
|
{t('wo.reset')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { X, Plus, Loader2 } from 'lucide-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 {
|
import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
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 };
|
type Meta = { code: string; count: number; can_update: boolean };
|
||||||
|
|
||||||
// Fields auto-derived from structured QSO data — their awards (DXCC/WAZ/WAS/…)
|
// 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]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
|
Promise.all([GetAwardDefs(), GetAwardReferenceMeta(), GetTrackedAwards()])
|
||||||
.then(([d, m]) => {
|
.then(([d, m, tr]) => {
|
||||||
setDefs((d ?? []) as any);
|
setDefs((d ?? []) as any);
|
||||||
setMetas(Object.fromEntries(((m ?? []) as Meta[]).map((x) => [String(x.code).toUpperCase(), x])));
|
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(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -111,6 +116,12 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
return defs.filter((d) => {
|
return defs.filter((d) => {
|
||||||
// Computed awards (field = dxcc/cqz/…) are derived automatically.
|
// Computed awards (field = dxcc/cqz/…) are derived automatically.
|
||||||
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
|
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 ?? [];
|
const scope = d.dxcc_filter ?? [];
|
||||||
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
|
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
|
||||||
// Offer the award even when its reference list isn't loaded yet: a custom
|
// 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;
|
return true;
|
||||||
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
|
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
|
||||||
.sort((a, b) => a.code.localeCompare(b.code));
|
.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.
|
// Keep the selected award valid as the offered list changes with the call.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -29,7 +29,32 @@ type AwardResult = {
|
|||||||
type AwardStatRow = { label: string; cells: number[]; total: number; grand_total: number };
|
type AwardStatRow = { label: string; cells: number[]; total: number; grand_total: number };
|
||||||
type AwardStats = { code: string; bands: string[]; rows: AwardStatRow[] };
|
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'];
|
const GRID_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm'];
|
||||||
|
|
||||||
// Per-band status for a reference, highest first.
|
// 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';
|
if (r.bands?.includes(band)) return 'worked';
|
||||||
return 'none';
|
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> = {
|
const CELL_STYLE: Record<CellStatus, string> = {
|
||||||
validated: 'bg-success text-success-foreground',
|
validated: 'bg-success text-success-foreground',
|
||||||
confirmed: 'bg-warning text-warning-foreground',
|
confirmed: 'bg-warning text-warning-foreground',
|
||||||
@@ -87,7 +125,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
|||||||
const [refSearch, setRefSearch] = useState('');
|
const [refSearch, setRefSearch] = useState('');
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
||||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf' | 'slots_notconf'>('all');
|
||||||
// Mode filter, stacked ON TOP of the status one. "Worked on CW but not
|
// 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
|
// confirmed" is two questions at once, and answering only one of them is what
|
||||||
// sends an operator to a spreadsheet.
|
// 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]);
|
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
|
// 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
|
// matrix so neither is padded with bands nothing was worked on. Rule: the
|
||||||
// award's explicit valid_bands if it has any (e.g. WAPC = 40/20/15/10m); else
|
// bands this operator HAS contacts on for this award; failing that, the
|
||||||
// the bands the operator actually has contacts on (so DXCC, which has no band
|
// award's own list of permitted bands. Empty set = no basis at all → callers
|
||||||
// restriction, shows 160m–6m instead of empty VHF/UHF columns). Empty set =
|
// fall back to the classic columns.
|
||||||
// no basis yet → callers fall back to all bands.
|
|
||||||
const awardBands = useMemo(() => {
|
const awardBands = useMemo(() => {
|
||||||
const vb = (awardList.find((a) => a.code === selected)?.bands ?? []).map((b) => b.toLowerCase());
|
// The bands with CONTACTS on them come first, whatever the award declares.
|
||||||
if (vb.length > 0) return new Set(vb);
|
//
|
||||||
const worked = (current?.bands ?? [])
|
// 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)
|
.filter((b) => (b.worked ?? 0) > 0)
|
||||||
.map((b) => String(b.band).toLowerCase());
|
.map((b) => String(b.band).toLowerCase()));
|
||||||
return new Set(worked);
|
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]);
|
}, [awardList, selected, current]);
|
||||||
|
|
||||||
const gridBands = useMemo(() => {
|
const gridBands = useMemo(() => {
|
||||||
if (awardBands.size === 0) return GRID_BANDS;
|
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;
|
return filtered.length ? filtered : GRID_BANDS;
|
||||||
}, [awardBands]);
|
}, [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
|
// 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
|
// 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.
|
// 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 === 'worked' && !r.worked) return false;
|
||||||
if (refFilter === 'notworked' && r.worked) return false;
|
if (refFilter === 'notworked' && r.worked) return false;
|
||||||
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||||
|
// 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') {
|
if (modeFilter !== 'all' && refFilter !== 'notworked') {
|
||||||
// A reference never worked has no mode, so "not worked" plus a mode is
|
// A reference never worked has no mode, so "not worked" plus a mode is
|
||||||
// a contradiction: the mode filter stands aside rather than emptying
|
// 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;
|
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
|
// 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
|
// (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')}
|
{t('awp.rescan')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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. */}
|
{/* Quick selector — scales when there are many awards. */}
|
||||||
{awardList.length > 0 && (
|
{awardList.length > 0 && (
|
||||||
<div className="px-2 py-2 border-b border-border/40">
|
<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)} />
|
<Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
<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)}
|
<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')}>
|
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||||
{label}
|
{label}
|
||||||
@@ -433,6 +508,11 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||||
|
{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
|
{/* 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
|
scope but with no reference" needs a scope to be in: on a
|
||||||
worldwide reference award — POTA, SOTA, IOTA, WWFF — every
|
worldwide reference award — POTA, SOTA, IOTA, WWFF — every
|
||||||
@@ -473,8 +553,8 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
|||||||
<tr className="bg-card">
|
<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>
|
<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>)}
|
{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" title={t('awp.totalHint')}>{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.grandHint')}>{t('awp.grand')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -493,6 +573,19 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</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>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
|
||||||
{s === 'none' ? <span className="block w-11 h-7" /> : (
|
{s === 'none' ? <span className="block w-11 h-7" /> : (
|
||||||
<button
|
<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 })}
|
title={t('awp.cellTitle', { ref: r.ref, band: b })}
|
||||||
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
||||||
>{CELL_LABEL[s]}</button>
|
>{CELL_LABEL[s]}</button>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
|
||||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||||
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
||||||
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||||
@@ -76,6 +77,13 @@ const BMP_MARKER_LABEL: Record<string, string> = {
|
|||||||
new_pota: 'bmp.legendNewPota',
|
new_pota: 'bmp.legendNewPota',
|
||||||
new_county: 'bmp.legendNewCounty',
|
new_county: 'bmp.legendNewCounty',
|
||||||
worked_call: 'bmp.legendWorkedCall',
|
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 {
|
interface Props {
|
||||||
@@ -107,59 +115,26 @@ interface Props {
|
|||||||
keyNav?: boolean;
|
keyNav?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BAND_RANGES: Record<string, [number, number]> = {
|
// Band edges and mode segments come from lib/bandplan, which knows the
|
||||||
'160m': [1800, 2000],
|
// operator's IARU region (Settings → General): a Region 2 operator's 40 m runs
|
||||||
'80m': [3500, 3800],
|
// to 7300 with phone from 7125, and drawing Region 1's plan under their spots
|
||||||
'60m': [5350, 5450],
|
// cut the top 100 kHz of the band off the map.
|
||||||
'40m': [7000, 7200],
|
//
|
||||||
'30m': [10100, 10150],
|
// Sub-band shading colours: these are IDENTITIES (which mode the segment is
|
||||||
'20m': [14000, 14350],
|
// for), not states, so they take categorical hues — never the status tokens.
|
||||||
'17m': [18068, 18168],
|
// All three are drawn from the cool end of the categorical order and laid down
|
||||||
'15m': [21000, 21450],
|
// at low opacity, so the band plan stays background context while the warm
|
||||||
'12m': [24890, 24990],
|
// spot pills keep the foreground. Checked with the palette validator in both
|
||||||
'10m': [28000, 29700],
|
// themes (violet failed the dark protan step; magenta clears every check).
|
||||||
'6m': [50000, 50500],
|
const SEG_COLOR: Record<SegMode, string> = {
|
||||||
'4m': [70000, 70500],
|
cw: 'var(--chart-7)', // magenta
|
||||||
'2m': [144000, 146000],
|
digi: 'var(--chart-1)', // blue
|
||||||
'70cm': [430000, 440000],
|
phone: 'var(--chart-2)', // aqua
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sub-band shading: CW / digital / phone.
|
|
||||||
//
|
|
||||||
// These are IDENTITIES (which mode the segment is for), not states, so they take
|
|
||||||
// categorical hues — never the status tokens. They used to use success / info /
|
|
||||||
// warning, which collided head-on with the spot pills drawn ON TOP of them: amber
|
|
||||||
// is "new band" in this very component's legend, so the whole SSB portion read as
|
|
||||||
// a giant "new band" wash. Status colours are reserved for status.
|
|
||||||
//
|
|
||||||
// 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 to themselves.
|
|
||||||
// Checked with the palette validator in BOTH themes. Violet was the first pick
|
|
||||||
// for CW and failed on the dark steps — violet and blue land 1.9 ΔE apart for a
|
|
||||||
// protan reader there, i.e. the same colour. Magenta clears every check on both
|
|
||||||
// surfaces (worst adjacent pair 13.0 light / 15.9 dark).
|
|
||||||
const SEG_CW = 'var(--chart-7)'; // magenta
|
|
||||||
const SEG_DIGI = 'var(--chart-1)'; // blue
|
|
||||||
const SEG_PHONE = 'var(--chart-2)'; // aqua
|
|
||||||
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
|
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
|
||||||
// are read on top of it.
|
// are read on top of it.
|
||||||
const SEG_OPACITY = 0.13;
|
const SEG_OPACITY = 0.13;
|
||||||
|
|
||||||
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
|
||||||
'160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
|
|
||||||
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
|
|
||||||
'60m': [[5350, 5450, SEG_PHONE]],
|
|
||||||
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
|
|
||||||
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
|
|
||||||
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
|
|
||||||
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
|
|
||||||
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
|
|
||||||
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
|
|
||||||
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
|
|
||||||
'6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Small coloured dot + label used in the band-map legend strip.
|
// Small coloured dot + label used in the band-map legend strip.
|
||||||
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
|
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -297,14 +272,28 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
// nothing until the next poll happened to hand over a fresh object.
|
// nothing until the next poll happened to hand over a fresh object.
|
||||||
const dispOpts = readSpotDisplayOptions();
|
const dispOpts = readSpotDisplayOptions();
|
||||||
const spotStatus = useMemo(() => {
|
const spotStatus = useMemo(() => {
|
||||||
if (!dispOpts.muteWorked && !dispOpts.slotHighlight) return spotStatusRaw;
|
if (!dispOpts.muteWorked && !dispOpts.slotHighlight && dispOpts.chasePota) return spotStatusRaw;
|
||||||
const out: Record<string, SpotStatusEntry> = {};
|
const out: Record<string, SpotStatusEntry> = {};
|
||||||
for (const k of Object.keys(spotStatusRaw)) out[k] = applySpotDisplay(spotStatusRaw[k], dispOpts) as SpotStatusEntry;
|
for (const k of Object.keys(spotStatusRaw)) out[k] = applySpotDisplay(spotStatusRaw[k], dispOpts) as SpotStatusEntry;
|
||||||
return out;
|
return out;
|
||||||
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
|
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight, dispOpts.chasePota]);
|
||||||
const range = BAND_RANGES[band];
|
// Re-render when the operator changes their IARU region in Settings.
|
||||||
const segments = SEGMENT_COLORS[band] ?? [];
|
const [, setRegionTick] = useState(0);
|
||||||
|
useEffect(() => subscribeIaruRegion(() => setRegionTick((n) => n + 1)), []);
|
||||||
|
const range = bandRange(band);
|
||||||
|
const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]);
|
||||||
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
|
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
|
||||||
|
// The legend is a reference, not a running display: once its colours are
|
||||||
|
// learnt it is four lines of a short screen spent saying nothing new. Folded
|
||||||
|
// away by a toggle, and the choice is remembered.
|
||||||
|
const [legendOpen, setLegendOpen] = useState(() => {
|
||||||
|
try { return localStorage.getItem('opslog.bmpLegend') !== '0'; } catch { return true; }
|
||||||
|
});
|
||||||
|
const toggleLegend = () => setLegendOpen((v) => {
|
||||||
|
const next = !v;
|
||||||
|
try { localStorage.setItem('opslog.bmpLegend', next ? '1' : '0'); } catch { /* private mode */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
// The docked map follows the rig, so a band change must bring up THAT band's
|
// The docked map follows the rig, so a band change must bring up THAT band's
|
||||||
// remembered zoom.
|
// remembered zoom.
|
||||||
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
|
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
|
||||||
@@ -482,19 +471,10 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
||||||
|
|
||||||
useEffect(() => {
|
// No ctrl+wheel zoom here any more: ctrl+wheel is the WINDOW zoom everywhere
|
||||||
const el = scrollerRef.current;
|
// else in OpsLog (View ▸ Zoom in/out), and one gesture that resizes the whole
|
||||||
if (!el) return;
|
// app over one panel and one band map over another is a gesture nobody can
|
||||||
const onWheel = (e: WheelEvent) => {
|
// trust. The + / − buttons keep the zoom, deliberately and visibly.
|
||||||
if (!range) return;
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
changeZoom(e.deltaY > 0 ? -1 : 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
el.addEventListener('wheel', onWheel, { passive: false });
|
|
||||||
return () => el.removeEventListener('wheel', onWheel);
|
|
||||||
}, [range]);
|
|
||||||
|
|
||||||
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
||||||
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
||||||
@@ -737,7 +717,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
'hover:translate-x-0.5 hover:shadow',
|
'hover:translate-x-0.5 hover:shadow',
|
||||||
style.pill,
|
style.pill,
|
||||||
)}
|
)}
|
||||||
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${markersFor(entry).map((m) => ' · ' + t(BMP_MARKER_LABEL[m.key])).join('')}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${activeMarkers(entry).map((m) => ' · ' + t(BMP_MARKER_LABEL[m.key])).join('')}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||||
>
|
>
|
||||||
{/* Left accent strip. With no extra marker it repeats the status
|
{/* Left accent strip. With no extra marker it repeats the status
|
||||||
colour, exactly as before; otherwise it splits into one
|
colour, exactly as before; otherwise it splits into one
|
||||||
@@ -775,6 +755,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Colour legend — what each pill colour means. */}
|
{/* Colour legend — what each pill colour means. */}
|
||||||
|
{legendOpen && (
|
||||||
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
||||||
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
||||||
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
||||||
@@ -787,13 +768,22 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
))}
|
))}
|
||||||
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
|
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
|
||||||
<span className="mx-0.5 opacity-40">|</span>
|
<span className="mx-0.5 opacity-40">|</span>
|
||||||
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
|
<LegendDot colour={SEG_COLOR.cw} label={t("bmp.legendCW")} />
|
||||||
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
|
<LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
|
||||||
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
|
<LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
)}
|
||||||
{t('bmp.footerHint')}
|
<div className="px-3 py-1 flex items-center gap-2 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono shrink-0">
|
||||||
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
<span className="flex-1 text-center">
|
||||||
|
{t('bmp.footerHint')}
|
||||||
|
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={toggleLegend}
|
||||||
|
title={legendOpen ? t('bmp.legendHide') : t('bmp.legendShow')}
|
||||||
|
aria-label={legendOpen ? t('bmp.legendHide') : t('bmp.legendShow')}
|
||||||
|
className="shrink-0 inline-flex items-center gap-0.5 rounded px-1 py-px hover:bg-muted hover:text-foreground">
|
||||||
|
{legendOpen ? <ChevronDown className="size-3" /> : <ChevronUp className="size-3" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ interface Props {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
currentBand: string;
|
currentBand: string;
|
||||||
currentMode: string;
|
currentMode: string;
|
||||||
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
bands?: string[];
|
||||||
|
// The operator's configured mode list, in THEIR order: the digital row
|
||||||
|
// rotates through it.
|
||||||
|
modes?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
||||||
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
||||||
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
|
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
|
||||||
// to an entity with no position at all, and the block simply does not appear.
|
// to an entity with no position at all, and the block simply does not appear.
|
||||||
@@ -81,29 +84,71 @@ const STATUS_CLASSES: Record<string, string> = {
|
|||||||
// i18n keys the Appearance panel's colour pickers use, so the two can never
|
// i18n keys the Appearance panel's colour pickers use, so the two can never
|
||||||
// disagree about which green is which. swatch = the background class (or a
|
// disagree about which green is which. swatch = the background class (or a
|
||||||
// special ring marker for the current-entry cell).
|
// special ring marker for the current-entry cell).
|
||||||
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
|
const LEGEND: { swatch: string; ring?: boolean; mark?: string; label: string }[] = [
|
||||||
{ swatch: 'bg-mx-call-conf', label: 'mx.callConf' },
|
{ swatch: 'bg-mx-call-conf', label: 'mx.callConf' },
|
||||||
{ swatch: 'bg-mx-call-work', label: 'mx.callWork' },
|
{ swatch: 'bg-mx-call-work', label: 'mx.callWork' },
|
||||||
{ swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' },
|
{ swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' },
|
||||||
{ swatch: 'bg-mx-dx-work', label: 'mx.dxWork' },
|
{ swatch: 'bg-mx-dx-work', label: 'mx.dxWork' },
|
||||||
{ swatch: 'bg-mx-none', label: 'mx.none' },
|
{ swatch: 'bg-mx-none', label: 'mx.none' },
|
||||||
{ swatch: 'bg-mx-none', ring: true, label: 'mx.current' },
|
{ swatch: 'bg-mx-none', ring: true, label: 'mx.current' },
|
||||||
|
{ swatch: 'bg-mx-none', mark: 'w', label: 'mx.markWork' },
|
||||||
|
{ swatch: 'bg-mx-none', mark: 'c', label: 'mx.markConf' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean): string {
|
// CallMark — "this callsign has already been worked on this slot".
|
||||||
|
//
|
||||||
|
// Drawn the same way on every cell, whatever colour the entity status gave it:
|
||||||
|
// the operator learns one shape and reads it without first working out what the
|
||||||
|
// background means. Only the fill changes, and only with the callsign's own
|
||||||
|
// state (worked / confirmed) — never with the entity's. The ring is the theme
|
||||||
|
// background, which is what keeps the dot legible over all five cell colours.
|
||||||
|
function CallMark({ state = 'w' }: { state?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute top-[4px] right-[4px] size-[5px] rounded-full ring-1 ring-background',
|
||||||
|
state === 'c' ? 'bg-mx-mark-conf' : 'bg-mx-mark-work',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean, call = ''): string {
|
||||||
const desc =
|
const desc =
|
||||||
status === 'call_c' ? t('mx.tipCallConf') :
|
status === 'call_c' ? t('mx.tipCallConf') :
|
||||||
status === 'call_w' ? t('mx.tipCallWork') :
|
status === 'call_w' ? t('mx.tipCallWork') :
|
||||||
status === 'dxcc_c' ? t('mx.tipDxConf') :
|
status === 'dxcc_c' ? t('mx.tipDxConf') :
|
||||||
status === 'dxcc_w' ? t('mx.tipDxWork') :
|
status === 'dxcc_w' ? t('mx.tipDxWork') :
|
||||||
t('mx.tipNone');
|
t('mx.tipNone');
|
||||||
return `${band} ${cls}: ${desc}${current ? ' — ' + t('mx.current') : ''}`;
|
const mine = call === 'c' ? t('mx.tipThisCallConf') : call === 'w' ? t('mx.tipThisCall') : '';
|
||||||
|
return `${band} ${cls}: ${desc}${mine ? ' — ' + mine : ''}${current ? ' — ' + t('mx.current') : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
|
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, modes, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
// Cell drill-down: which band+class the operator clicked, or null.
|
// Cell drill-down: which band+class the operator clicked, or null.
|
||||||
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
|
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
|
||||||
|
|
||||||
|
// The DIGITAL row is a rotation, not a fixed row.
|
||||||
|
//
|
||||||
|
// One row for every digital mode would be the honest layout and there is no
|
||||||
|
// height for it — the matrix sits in a fixed panel beside a dozen widgets.
|
||||||
|
// So the row keeps its place and changes what it answers: DIG (all of them),
|
||||||
|
// then each digital mode the operator actually uses, in the order their mode
|
||||||
|
// list gives, then back to DIG. The backend publishes the same cells under
|
||||||
|
// both the class name and the raw mode, so a rotation costs no round trip.
|
||||||
|
const digModes = useMemo(
|
||||||
|
() => (modes ?? [])
|
||||||
|
.map((m) => (m || '').toUpperCase().trim())
|
||||||
|
.filter((m) => m !== '' && m !== 'CW' && !PHONE_MODES.has(m)),
|
||||||
|
[modes],
|
||||||
|
);
|
||||||
|
const [digIdx, setDigIdx] = useState(0); // 0 = the DIG group itself
|
||||||
|
// A shorter mode list (the operator edited it) must not strand the rotation
|
||||||
|
// on a row that no longer exists.
|
||||||
|
const digPos = digModes.length ? digIdx % (digModes.length + 1) : 0;
|
||||||
|
const digRow = digPos === 0 ? 'DIG' : digModes[digPos - 1];
|
||||||
|
const cycleDig = () => setDigIdx((i) => (digModes.length ? (i + 1) % (digModes.length + 1) : 0));
|
||||||
// Columns from the operator's configured bands (so the matrix shows only the
|
// Columns from the operator's configured bands (so the matrix shows only the
|
||||||
// bands they actually use), falling back to the built-in default set.
|
// bands they actually use), falling back to the built-in default set.
|
||||||
const cols = useMemo(
|
const cols = useMemo(
|
||||||
@@ -126,6 +171,16 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
return m;
|
return m;
|
||||||
}, [wb]);
|
}, [wb]);
|
||||||
|
|
||||||
|
// Worked-with-this-callsign, per cell — carried separately by the backend
|
||||||
|
// because collapsing it into the status is what hid it.
|
||||||
|
const callMap = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const s of wb?.band_status ?? []) {
|
||||||
|
if ((s as any).call) m.set(`${s.band}|${s.class}`, (s as any).call);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [wb]);
|
||||||
|
|
||||||
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
||||||
// Derived straight from the entity's real band_status (all bands it was
|
// Derived straight from the entity's real band_status (all bands it was
|
||||||
// worked on — not just the operator's configured column list).
|
// worked on — not just the operator's configured column list).
|
||||||
@@ -211,11 +266,11 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-4 px-3 py-2 flex-wrap shrink-0',
|
'flex items-center gap-2 px-2 py-2 flex-wrap shrink-0',
|
||||||
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
|
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-[220px] flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
{newOne ? (
|
{newOne ? (
|
||||||
<>
|
<>
|
||||||
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
||||||
@@ -238,7 +293,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
{forCall}
|
{forCall}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
<Badge className="bg-primary text-primary-foreground px-2 py-0.5 text-xs normal-case font-semibold tracking-normal leading-tight">
|
||||||
{dxccName || `DXCC #${dxcc}`}
|
{dxccName || `DXCC #${dxcc}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
{mwBadge}
|
{mwBadge}
|
||||||
@@ -279,7 +334,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
<table className="border-separate" style={{ borderSpacing: 3 }}>
|
<table className="border-separate" style={{ borderSpacing: 3 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-[26px]" />
|
<th className="w-[38px] min-w-[38px] max-w-[38px]" />
|
||||||
{cols.map((b) => (
|
{cols.map((b) => (
|
||||||
<th
|
<th
|
||||||
key={b.tag}
|
key={b.tag}
|
||||||
@@ -294,13 +349,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{CLASSES.map((cls) => {
|
{CLASSES.map((clsBase) => {
|
||||||
const classCurrent = classMatchesMode(cls, currentMode);
|
const cls = clsBase === 'DIG' ? digRow : clsBase;
|
||||||
|
// On a specific digital mode the "you are here" mark has to be that
|
||||||
|
// mode, not any digital one — otherwise every FT4 entry lights the
|
||||||
|
// FT8 row it happens to be cycled to.
|
||||||
|
const classCurrent = cls === clsBase
|
||||||
|
? classMatchesMode(cls, currentMode)
|
||||||
|
: (currentMode || '').toUpperCase() === cls;
|
||||||
return (
|
return (
|
||||||
<tr key={cls}>
|
<tr key={cls}>
|
||||||
<th
|
<th
|
||||||
|
onClick={clsBase === 'DIG' && digModes.length ? cycleDig : undefined}
|
||||||
|
title={clsBase === 'DIG' && digModes.length ? t('bsg.digCycle') : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono text-[11px] font-semibold pr-1.5 text-right w-[26px]',
|
// Sized once for the LONGEST label the rotation can show,
|
||||||
|
// and pinned there: a column that grows when RTTY comes
|
||||||
|
// round shifts every band beneath it, and the eye reads
|
||||||
|
// that as the matrix moving rather than the row changing.
|
||||||
|
'font-mono font-semibold pr-1.5 text-right w-[38px] min-w-[38px] max-w-[38px] overflow-hidden',
|
||||||
|
// Beyond four characters (PSK31, MSK144) the type gives way
|
||||||
|
// instead of the column.
|
||||||
|
cls.length > 4 ? 'text-[9px]' : 'text-[11px]',
|
||||||
|
clsBase === 'DIG' && digModes.length ? 'cursor-pointer hover:text-foreground' : '',
|
||||||
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
|
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -308,21 +379,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
</th>
|
</th>
|
||||||
{cols.map((b) => {
|
{cols.map((b) => {
|
||||||
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
|
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
|
||||||
|
// The same cell's other answer: worked with THIS callsign
|
||||||
|
// here. The status above is the entity's, and a confirmed
|
||||||
|
// entity outranks a worked call — so chasing a DXpedition,
|
||||||
|
// the cell could say "confirmed" about a contact made years
|
||||||
|
// ago and nothing about the one made this morning.
|
||||||
|
const mine = callMap.get(`${b.tag}|${cls}`) ?? '';
|
||||||
const isCurrent = hasCall && b.tag === currentBand && classCurrent;
|
const isCurrent = hasCall && b.tag === currentBand && classCurrent;
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={b.tag}
|
key={b.tag}
|
||||||
title={cellTitle(t, b.tag, cls, st, isCurrent) + (st ? ' — ' + t('mx.tipClick') : '')}
|
title={cellTitle(t, b.tag, cls, st, isCurrent, mine) + (st ? ' — ' + t('mx.tipClick') : '')}
|
||||||
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
|
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-[28px] h-[24px] rounded transition-colors p-0',
|
'relative w-[28px] h-[24px] rounded transition-colors p-0',
|
||||||
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
||||||
// Only a filled cell has anything to show — an empty one
|
// Only a filled cell has anything to show — an empty one
|
||||||
// stays inert rather than opening a "no QSOs" dialog.
|
// stays inert rather than opening a "no QSOs" dialog.
|
||||||
st && 'cursor-pointer hover:brightness-110',
|
st && 'cursor-pointer hover:brightness-110',
|
||||||
isCurrent && 'ring-2 ring-mx-cur ring-inset',
|
isCurrent && 'ring-2 ring-mx-cur ring-inset',
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
|
{mine ? <CallMark state={mine} /> : null}
|
||||||
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -337,11 +416,13 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
<span key={l.label} className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
<span key={l.label} className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-block size-3 rounded shrink-0',
|
'relative inline-block size-3 rounded shrink-0',
|
||||||
l.swatch,
|
l.swatch,
|
||||||
l.ring && 'ring-2 ring-mx-cur ring-inset',
|
l.ring && 'ring-2 ring-mx-cur ring-inset',
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
|
{l.mark ? <CallMark state={l.mark} /> : null}
|
||||||
|
</span>
|
||||||
{t(l.label)}
|
{t(l.label)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
|
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'clublog_rcvd', label: 'bulk.fClublogRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'clublog_rcvd_date', label: 'bulk.fClublogRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
|
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
// HAMLOG.online: no promoted column, written into extras_json (see
|
// HAMLOG.online: no promoted column, written into extras_json (see
|
||||||
@@ -54,12 +56,17 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'hamlog_sent_date', label: 'bulk.fHamlogSentDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'hamlog_sent_date', label: 'bulk.fHamlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'hamlog_rcvd', label: 'bulk.fHamlogRcvd', group: 'QSL / upload', kind: 'status' },
|
{ id: 'hamlog_rcvd', label: 'bulk.fHamlogRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'hamlog_rcvd_date', label: 'bulk.fHamlogRcvdDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'hamlog_rcvd_date', label: 'bulk.fHamlogRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'hamqth_sent', label: 'bulk.fHamqthSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'hamqth_sent_date', label: 'bulk.fHamqthSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
// My station / operator
|
// My station / operator
|
||||||
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
||||||
// No promoted column: written into extras_json (see qso.bulkEditableExtras).
|
// No promoted column: written into extras_json (see qso.bulkEditableExtras).
|
||||||
{ id: 'owner_callsign', label: 'bulk.fOwnerCallsign', group: 'My station', kind: 'text', upper: true },
|
{ id: 'owner_callsign', label: 'bulk.fOwnerCallsign', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
|
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_dxcc', label: 'bulk.fMyDxcc', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_cq_zone', label: 'bulk.fMyCqZone', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_itu_zone', label: 'bulk.fMyItuZone', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
|
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_street', label: 'bulk.fMyStreet', group: 'My station', kind: 'text' },
|
{ id: 'my_street', label: 'bulk.fMyStreet', group: 'My station', kind: 'text' },
|
||||||
|
|||||||
@@ -10,10 +10,12 @@
|
|||||||
// new is being decoded on FT8/FT4/JS8 near here — not that the band is dead.
|
// new is being decoded on FT8/FT4/JS8 near here — not that the band is dead.
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Radar, Loader2, X } from 'lucide-react';
|
import { Radar, Loader2, X } from 'lucide-react';
|
||||||
|
import { formatDistance } from '@/lib/units';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { chaseAllows } from '@/lib/spotDisplay';
|
||||||
import { markerColour } from '@/lib/spotMarkers';
|
import { markerColour } from '@/lib/spotMarkers';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
|
import { GetChaseNewSpots, GetPSKReporterStatus } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
export interface ChaseNewSpot {
|
export interface ChaseNewSpot {
|
||||||
call: string;
|
call: string;
|
||||||
@@ -59,6 +61,10 @@ const CATEGORIES: Array<{ key: Category; labelKey: string; colour: string }> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// categoryOf is the single thing a row says about a station.
|
// categoryOf is the single thing a row says about a station.
|
||||||
|
//
|
||||||
|
// A category the operator does not chase is not a category here either: with
|
||||||
|
// prefixes and squares switched off this panel showed rows whose only reason
|
||||||
|
// for being listed had been withdrawn everywhere else.
|
||||||
function categoryOf(s: ChaseNewSpot): Category | null {
|
function categoryOf(s: ChaseNewSpot): Category | null {
|
||||||
switch (s.status) {
|
switch (s.status) {
|
||||||
case 'new': return 'dxcc';
|
case 'new': return 'dxcc';
|
||||||
@@ -67,22 +73,32 @@ function categoryOf(s: ChaseNewSpot): Category | null {
|
|||||||
case 'new-mode': return 'mode';
|
case 'new-mode': return 'mode';
|
||||||
case 'new-slot': return 'slot';
|
case 'new-slot': return 'slot';
|
||||||
}
|
}
|
||||||
if (s.new_pfx) return 'pfx';
|
if (s.new_pfx && chaseAllows('pfx')) return 'pfx';
|
||||||
if (s.new_grid) return 'grid';
|
if (s.new_grid && chaseAllows('grid')) return 'grid';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FILTER_KEY = 'opslog.chaseNewFilters';
|
const FILTER_KEY = 'opslog.chaseNewFilters';
|
||||||
|
|
||||||
|
function allowedCategories(): Category[] {
|
||||||
|
return CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key);
|
||||||
|
}
|
||||||
|
|
||||||
function loadFilters(): Set<Category> {
|
function loadFilters(): Set<Category> {
|
||||||
|
const allowed = allowedCategories();
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(FILTER_KEY);
|
const raw = localStorage.getItem(FILTER_KEY);
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const list = JSON.parse(raw) as Category[];
|
const list = JSON.parse(raw) as Category[];
|
||||||
if (Array.isArray(list)) return new Set(list);
|
// A stored set holding NONE of the categories on offer hides the whole
|
||||||
|
// panel, for ever, with nothing to say why — and that is exactly what a
|
||||||
|
// preference written by an older build does once a category is renamed.
|
||||||
|
// Treated as "no preference": a panel that shows nothing at every launch
|
||||||
|
// is never what was meant, and the chips are one click away.
|
||||||
|
if (Array.isArray(list) && list.some((k) => allowed.includes(k))) return new Set(list);
|
||||||
}
|
}
|
||||||
} catch { /* a corrupt preference is not worth a broken panel */ }
|
} catch { /* a corrupt preference is not worth a broken panel */ }
|
||||||
return new Set(CATEGORIES.map((c) => c.key));
|
return new Set(allowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChaseNewPanel({ onPick, onClose }: Props) {
|
export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||||
@@ -90,6 +106,10 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
|||||||
const [spots, setSpots] = useState<ChaseNewSpot[]>([]);
|
const [spots, setSpots] = useState<ChaseNewSpot[]>([]);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [on, setOn] = useState<Set<Category>>(loadFilters);
|
const [on, setOn] = useState<Set<Category>>(loadFilters);
|
||||||
|
// The FEED, not the list: connected or not, how many reports it has taken,
|
||||||
|
// and what it is filtered on. Without it an empty panel says nothing about
|
||||||
|
// whether anything is arriving at all — which is the first question.
|
||||||
|
const [feed, setFeed] = useState<any>(null);
|
||||||
|
|
||||||
// Polled rather than pushed: the feed can deliver several a second under an
|
// Polled rather than pushed: the feed can deliver several a second under an
|
||||||
// opening, and an event per row would be a redraw per row for a list nobody
|
// opening, and an event per row would be a redraw per row for a list nobody
|
||||||
@@ -100,6 +120,8 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
|||||||
try {
|
try {
|
||||||
const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[];
|
const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[];
|
||||||
if (alive) { setSpots(r); setLoaded(true); }
|
if (alive) { setSpots(r); setLoaded(true); }
|
||||||
|
const st = await GetPSKReporterStatus();
|
||||||
|
if (alive) setFeed(st);
|
||||||
} catch { /* the feed may not be up yet */ }
|
} catch { /* the feed may not be up yet */ }
|
||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
@@ -131,7 +153,7 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
|||||||
|
|
||||||
{/* Filters, in the same order and colours as the badges they hide. */}
|
{/* Filters, in the same order and colours as the badges they hide. */}
|
||||||
<div className="flex flex-1 flex-wrap items-center gap-1">
|
<div className="flex flex-1 flex-wrap items-center gap-1">
|
||||||
{CATEGORIES.map((c) => (
|
{CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => (
|
||||||
<button
|
<button
|
||||||
key={c.key}
|
key={c.key}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -148,8 +170,13 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Both numbers: what is on screen, and what was heard. They differ
|
||||||
|
exactly when a category is switched off, which is the one case an
|
||||||
|
operator reads this panel as broken. */}
|
||||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||||
{loaded ? t('chn.count', { n: shown.length }) : ''}
|
{loaded ? (shown.length === spots.length
|
||||||
|
? t('chn.count', { n: spots.length })
|
||||||
|
: t('chn.countOf', { n: shown.length, total: spots.length })) : ''}
|
||||||
</span>
|
</span>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
@@ -186,7 +213,7 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
|||||||
title={[
|
title={[
|
||||||
s.country,
|
s.country,
|
||||||
s.grid,
|
s.grid,
|
||||||
s.dist_km ? `${s.dist_km} km` : '',
|
s.dist_km ? formatDistance(s.dist_km) : '',
|
||||||
s.freq_hz ? `${(s.freq_hz / 1000).toFixed(1)} kHz` : '',
|
s.freq_hz ? `${(s.freq_hz / 1000).toFixed(1)} kHz` : '',
|
||||||
].filter(Boolean).join(' · ')}
|
].filter(Boolean).join(' · ')}
|
||||||
>
|
>
|
||||||
@@ -212,7 +239,16 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">{t('chn.digitalOnly')}</p>
|
{/* The radius comes from the FEED, not from a sentence: it was written
|
||||||
|
into this line as "~300 km" and stayed 300 while the setting said
|
||||||
|
1000, which is the panel telling the operator their change did not
|
||||||
|
take when it had. */}
|
||||||
|
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">
|
||||||
|
{t('chn.heardWithin', { km: feed?.near_km || 300 })}
|
||||||
|
{feed && (feed.running
|
||||||
|
? <span className="text-success"> · {t('chn.feedOn', { n: feed.received ?? 0, sq: feed.squares ?? 0 })}</span>
|
||||||
|
: <span className="text-warning"> · {feed.last_err ? t('chn.feedErr', { e: feed.last_err }) : t('chn.feedOff')}</span>)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||||
import { markerColour } from '@/lib/spotMarkers';
|
import { markerColour } from '@/lib/spotMarkers';
|
||||||
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
import { applySpotDisplay, chasePota, chaseSota, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||||
|
import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||||
@@ -45,6 +46,7 @@ export type ClusterSpot = {
|
|||||||
raw: string;
|
raw: string;
|
||||||
repeats?: number;
|
repeats?: number;
|
||||||
pota_ref?: string;
|
pota_ref?: string;
|
||||||
|
sota_ref?: string;
|
||||||
pota_name?: string;
|
pota_name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,6 +65,9 @@ export type SpotStatusEntry = {
|
|||||||
state?: string;
|
state?: string;
|
||||||
new_pota?: boolean;
|
new_pota?: boolean;
|
||||||
new_pfx?: boolean;
|
new_pfx?: boolean;
|
||||||
|
unconf_status?: boolean;
|
||||||
|
unconf_pfx?: boolean;
|
||||||
|
unconf_cty?: boolean;
|
||||||
pfx?: string;
|
pfx?: string;
|
||||||
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
|
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
|
||||||
lotw?: boolean;
|
lotw?: boolean;
|
||||||
@@ -82,6 +87,11 @@ type Props = {
|
|||||||
// stray click while looking took the operator off the station they were
|
// stray click while looking took the operator off the station they were
|
||||||
// working. Looking and going are now two different gestures.
|
// working. Looking and going are now two different gestures.
|
||||||
onSpotSelect?: (s: ClusterSpot) => void;
|
onSpotSelect?: (s: ClusterSpot) => void;
|
||||||
|
// Anything the caller wants on the LEFT of the toolbar — the pane's title, its
|
||||||
|
// live count, its Filters button. Docked in a pane, the title used to sit on a
|
||||||
|
// row of its own above this one, so the cluster ate two lines of a short pane
|
||||||
|
// where Recent QSOs beside it ate one. Reported by VK4DX.
|
||||||
|
headerLeft?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const COL_STATE_KEY = 'hamlog.clusterColState.v1';
|
const COL_STATE_KEY = 'hamlog.clusterColState.v1';
|
||||||
@@ -141,6 +151,12 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
|||||||
// filled pfx → new prefix filled POTA → new park filled county → new county
|
// filled pfx → new prefix filled POTA → new park filled county → new county
|
||||||
// blue call → already worked (not a novelty, so text only)
|
// blue call → already worked (not a novelty, so text only)
|
||||||
const NEW = 'var(--warning)'; // yellow: something here is new
|
const NEW = 'var(--warning)'; // yellow: something here is new
|
||||||
|
// NEW SLOT gets its own hue. It shared the amber NEW family while the NEW PFX
|
||||||
|
// marker sits in caution yellow — two different facts, two near-identical
|
||||||
|
// colours in the same cell. Sky cyan, the exact colour the panadapter palette
|
||||||
|
// ships for new-slot (#5AC8FA), so the two views tell one story — and clearly
|
||||||
|
// apart from the POTA green the first attempt (aqua) sat next to.
|
||||||
|
const NEWSLOT = '#5AC8FA';
|
||||||
const WKD = 'var(--info)'; // blue: this callsign is already in the log
|
const WKD = 'var(--info)'; // blue: this callsign is already in the log
|
||||||
|
|
||||||
// FILLING the cell that carries the fact, rather than only tinting its text.
|
// FILLING the cell that carries the fact, rather than only tinting its text.
|
||||||
@@ -183,9 +199,10 @@ function statusColor(s: SpotStatusEntry | undefined): string | null {
|
|||||||
case 'new-band-mode':
|
case 'new-band-mode':
|
||||||
case 'new-band':
|
case 'new-band':
|
||||||
case 'new-mode':
|
case 'new-mode':
|
||||||
case 'new-slot':
|
|
||||||
case 'new-call':
|
case 'new-call':
|
||||||
return NEW;
|
return NEW;
|
||||||
|
case 'new-slot':
|
||||||
|
return NEWSLOT;
|
||||||
default:
|
default:
|
||||||
return s?.worked_call ? WKD : null;
|
return s?.worked_call ? WKD : null;
|
||||||
}
|
}
|
||||||
@@ -290,13 +307,15 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
||||||
: s?.status === 'new-call' ? t('clg2.newCall')
|
: s?.status === 'new-call' ? t('clg2.newCall')
|
||||||
: t('clg2.wkdCall');
|
: t('clg2.wkdCall');
|
||||||
parts.push({ text: label, color: main });
|
// Dimmed when the need is only a missing confirmation — the grid's
|
||||||
|
// own convention, now spoken by every category.
|
||||||
|
parts.push(s?.unconf_status ? { text: label, color: main, dim: true } : { text: label, color: main });
|
||||||
}
|
}
|
||||||
// Colours from lib/spotMarkers — shared with the band map so a marker is
|
// Colours from lib/spotMarkers — shared with the band map so a marker is
|
||||||
// never one colour here and another there.
|
// never one colour here and another there.
|
||||||
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county') });
|
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county'), dim: !!s?.unconf_cty });
|
||||||
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
|
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
|
||||||
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx') });
|
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx'), dim: !!s?.unconf_pfx });
|
||||||
// Worked-but-unconfirmed is a QSL to chase, not a QSO to make. Same hue
|
// Worked-but-unconfirmed is a QSL to chase, not a QSO to make. Same hue
|
||||||
// held back, so it reads as "less" of the same thing rather than a
|
// held back, so it reads as "less" of the same thing rather than a
|
||||||
// different fact — and the label says which.
|
// different fact — and the label says which.
|
||||||
@@ -329,13 +348,26 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
||||||
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
headerName: t('clg2.c.pota'), width: 92, cellClass: 'font-mono',
|
||||||
|
// Through a valueGetter so the chase switch empties the column live.
|
||||||
|
valueGetter: (p: any) => (chasePota() ? p.data?.pota_ref ?? '' : ''),
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: (p: any) => (statusFor(p)?.new_pota
|
cellStyle: (p: any) => (statusFor(p)?.new_pota
|
||||||
? fillStyle(markerColour('new_pota'))
|
? fillStyle(markerColour('new_pota'))
|
||||||
: { color: 'var(--success)' }) as any,
|
: { color: 'var(--success)' }) as any,
|
||||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// SOTA sits next to POTA and reads the same way. The reference comes from the
|
||||||
|
// spot's comment, so it is present on the SOTA feeds and empty elsewhere —
|
||||||
|
// which is why the column is off by default rather than an empty column for
|
||||||
|
// everyone who does not watch summits.
|
||||||
|
group: 'Spot', label: t('clg2.c.sota'), colId: 'sota',
|
||||||
|
headerName: t('clg2.c.sota'), width: 100, cellClass: 'font-mono',
|
||||||
|
valueGetter: (p: any) => (chaseSota() ? p.data?.sota_ref ?? '' : ''),
|
||||||
|
defaultVisible: false,
|
||||||
|
cellStyle: () => ({ color: 'var(--success)' }) as any,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
|
group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
|
||||||
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
@@ -432,8 +464,13 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
|
group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
|
||||||
headerName: t('clg2.h.distance_km'), field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
// The header carries the unit, so the cells stay bare numbers and the
|
||||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
// column still sorts on the km the backend sent — converting the VALUE
|
||||||
|
// would sort miles as if they were kilometres either way, but it would
|
||||||
|
// also round twice.
|
||||||
|
headerName: t('clg2.h.distance_km') + ' (' + distanceUnit() + ')',
|
||||||
|
field: 'distance_km' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
|
valueFormatter: (p) => p.value ? String(distanceValue(p.value)) : '',
|
||||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -506,13 +543,15 @@ const GROUP_ORDER = ['Spot', 'Geo'];
|
|||||||
const CLG_GRP_KEYS: Record<string, string> = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' };
|
const CLG_GRP_KEYS: Record<string, string> = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' };
|
||||||
const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g);
|
const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g);
|
||||||
|
|
||||||
export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Props) {
|
export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect, headerLeft }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
// Localized column catalog — rebuilt when the language changes.
|
// Localized column catalog — rebuilt when the language changes.
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
const [distUnit, setDistUnit] = useState(distanceUnit);
|
||||||
|
useEffect(() => subscribeDistanceUnit(() => setDistUnit(distanceUnit())), []);
|
||||||
|
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t, distUnit]);
|
||||||
|
|
||||||
// A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
|
// A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
|
||||||
// matching column events. Without this guard those events were persisted, so a
|
// matching column events. Without this guard those events were persisted, so a
|
||||||
@@ -588,6 +627,55 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Pro
|
|||||||
if (state) saveState(COL_STATE_KEY, state);
|
if (state) saveState(COL_STATE_KEY, state);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// HOLDING THE LIST STILL WHILE IT IS BEING READ.
|
||||||
|
//
|
||||||
|
// A busy evening puts a spot on the list every second or two, each landing at
|
||||||
|
// the top and pushing everything below it down. At the top of the list that is
|
||||||
|
// exactly right — it is what makes the panel live. A few rows down it makes
|
||||||
|
// the panel unusable: the callsign under the pointer has moved by the time the
|
||||||
|
// click lands, and the operator ends up chasing the row they wanted.
|
||||||
|
//
|
||||||
|
// So the grid freezes as soon as it is scrolled away from the top, and says
|
||||||
|
// how many spots are waiting. Scrolling back to the top releases it, as does
|
||||||
|
// clicking the notice. Nothing is lost — the spots keep arriving in the shared
|
||||||
|
// list; this only decides when the GRID is allowed to redraw with them.
|
||||||
|
const [held, setHeld] = useState<ClusterSpot[] | null>(null);
|
||||||
|
const shown = held ?? rows;
|
||||||
|
|
||||||
|
// How many arrived since the freeze — counted by TIME, not by finding the
|
||||||
|
// frozen top row again.
|
||||||
|
//
|
||||||
|
// Looking for that row was wrong in the ordinary case: a station spotted again
|
||||||
|
// REPLACES its row (that is the de-dupe), so the row we froze on disappears
|
||||||
|
// from the live list the moment somebody re-spots it — and the count fell
|
||||||
|
// through to "everything is new", jumping from 4 to the buffer cap. Reported
|
||||||
|
// as "it shows 4, 5 new spots and then 500 all at once".
|
||||||
|
//
|
||||||
|
// A timestamp survives both the replacement and the ring buffer, which was the
|
||||||
|
// reason the length was not used either.
|
||||||
|
const spotTime = (r: ClusterSpot) => Date.parse(String((r as any).received_at ?? '')) || 0;
|
||||||
|
const waiting = useMemo(() => {
|
||||||
|
if (!held || held.length === 0) return 0;
|
||||||
|
const since = spotTime(held[0]);
|
||||||
|
if (!since) return 0; // no usable timestamp — say nothing rather than a number
|
||||||
|
let n = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (spotTime(r) > since) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}, [held, rows]);
|
||||||
|
|
||||||
|
const onBodyScroll = (e: { top: number }) => {
|
||||||
|
const down = e.top > 4; // a pixel or two of overscroll is not "scrolled down"
|
||||||
|
if (!down && held) setHeld(null);
|
||||||
|
if (down && !held) setHeld(rows);
|
||||||
|
};
|
||||||
|
|
||||||
|
const release = () => {
|
||||||
|
setHeld(null);
|
||||||
|
gridRef.current?.api?.ensureIndexVisible(0, 'top');
|
||||||
|
};
|
||||||
|
|
||||||
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
|
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
|
||||||
if (e.data && onSpotSelect) onSpotSelect(e.data);
|
if (e.data && onSpotSelect) onSpotSelect(e.data);
|
||||||
}
|
}
|
||||||
@@ -632,7 +720,9 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Pro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
<div className="flex items-center gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||||
|
{headerLeft}
|
||||||
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||||
title={t('clg2.clearFiltersTitle')}>
|
title={t('clg2.clearFiltersTitle')}>
|
||||||
<FilterX className="size-3.5" /> {t('clg2.clearFilters')}
|
<FilterX className="size-3.5" /> {t('clg2.clearFilters')}
|
||||||
@@ -646,7 +736,7 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Pro
|
|||||||
<AgGridReact<ClusterSpot>
|
<AgGridReact<ClusterSpot>
|
||||||
ref={gridRef}
|
ref={gridRef}
|
||||||
theme={hamlogTheme}
|
theme={hamlogTheme}
|
||||||
rowData={rows}
|
rowData={shown}
|
||||||
columnDefs={columnDefs}
|
columnDefs={columnDefs}
|
||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
context={context}
|
context={context}
|
||||||
@@ -658,11 +748,20 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Pro
|
|||||||
onSortChanged={saveColumnState}
|
onSortChanged={saveColumnState}
|
||||||
onRowClicked={handleRowClicked}
|
onRowClicked={handleRowClicked}
|
||||||
onRowDoubleClicked={handleRowDoubleClicked}
|
onRowDoubleClicked={handleRowDoubleClicked}
|
||||||
|
onBodyScroll={onBodyScroll}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
suppressCellFocus
|
suppressCellFocus
|
||||||
getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`}
|
getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Only when something is actually waiting: a frozen list with nothing
|
||||||
|
new to show needs no announcement. */}
|
||||||
|
{waiting > 0 && (
|
||||||
|
<button type="button" onClick={release}
|
||||||
|
className="absolute left-1/2 -translate-x-1/2 top-1 z-10 rounded-full border border-primary bg-primary px-3 py-1 text-[11px] font-semibold text-primary-foreground shadow-lg hover:opacity-90">
|
||||||
|
{t('clg2.newSpots', { n: waiting })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { RefreshCw, Star, ExternalLink } from 'lucide-react';
|
||||||
|
import { GetDXpeditions, GetDXWorldNews, RefreshDXpeditions, WatchlistEntries, WatchlistAdd } from '../../wailsjs/go/main/App';
|
||||||
|
import { BrowserOpenURL, EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
// DXpeditions — the two feeds the DX world announces itself on, side by side.
|
||||||
|
//
|
||||||
|
// Left: NG3K's ADXO, the structured announcements, each judged against THIS log
|
||||||
|
// so the list reads as "what I still need" rather than "what is on". Right:
|
||||||
|
// DX-World's headlines, whose callsigns are mined out of the title so they can
|
||||||
|
// be watched with the same one click.
|
||||||
|
|
||||||
|
type DXped = {
|
||||||
|
dxcc: string; callsign: string; calls?: string[];
|
||||||
|
start_date: string; end_date: string;
|
||||||
|
bands?: string[]; modes?: string[];
|
||||||
|
qsl: string; operators: string; source: string; link: string;
|
||||||
|
status: string; // active | upcoming
|
||||||
|
status_chase: string; // new | new-band-mode | new-band | new-mode | new-slot | worked | ''
|
||||||
|
unconfirmed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type News = {
|
||||||
|
title: string; link: string; pub_date: string; excerpt: string;
|
||||||
|
creator: string; image_url: string; tag: string; calls?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// One badge per expedition, the strongest verdict winning — the same palette
|
||||||
|
// and the same words the cluster uses, so the two views teach one vocabulary.
|
||||||
|
const CHASE_BADGE: Record<string, { label: string; colour: string }> = {
|
||||||
|
'new': { label: 'clg2.newDxcc', colour: 'var(--danger)' },
|
||||||
|
'new-band-mode': { label: 'clg2.newBandMode', colour: 'var(--danger)' },
|
||||||
|
'new-band': { label: 'clg2.newBand', colour: 'var(--warning)' },
|
||||||
|
'new-mode': { label: 'clg2.newMode', colour: 'var(--caution)' },
|
||||||
|
'new-slot': { label: 'clg2.newSlot', colour: '#5AC8FA' },
|
||||||
|
'worked': { label: 'wl.worked', colour: 'var(--info)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DXpeditionsPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [peds, setPeds] = useState<DXped[]>([]);
|
||||||
|
const [news, setNews] = useState<News[]>([]);
|
||||||
|
const [watched, setWatched] = useState<Set<string>>(new Set());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [neededOnly, setNeededOnly] = useState(() => localStorage.getItem('opslog.dxpedNeeded') === '1');
|
||||||
|
|
||||||
|
const loadWatchlist = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const e: any[] = await WatchlistEntries();
|
||||||
|
setWatched(new Set((e ?? []).map((x) => String(x.callsign ?? '').toUpperCase())));
|
||||||
|
} catch { /* the list simply shows every call as unwatched */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setErr('');
|
||||||
|
const [p, n] = await Promise.allSettled([GetDXpeditions(), GetDXWorldNews()]);
|
||||||
|
if (p.status === 'fulfilled') setPeds((p.value as any) ?? []);
|
||||||
|
else setErr(String((p.reason as any)?.message ?? p.reason));
|
||||||
|
if (n.status === 'fulfilled') setNews((n.value as any) ?? []);
|
||||||
|
setBusy(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { void load(); void loadWatchlist(); }, [load, loadWatchlist]);
|
||||||
|
useEffect(() => EventsOn('watchlist:changed', () => { void loadWatchlist(); }), [loadWatchlist]);
|
||||||
|
|
||||||
|
const refresh = async () => { await RefreshDXpeditions(); await load(); };
|
||||||
|
|
||||||
|
const addAll = async (calls: string[]) => {
|
||||||
|
for (const c of calls) {
|
||||||
|
if (!watched.has(c.toUpperCase())) {
|
||||||
|
try { await WatchlistAdd(c, false); } catch { /* reported by the notice */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadWatchlist();
|
||||||
|
};
|
||||||
|
|
||||||
|
const shown = useMemo(
|
||||||
|
() => (neededOnly ? peds.filter((p) => p.status_chase && p.status_chase !== 'worked') : peds),
|
||||||
|
[peds, neededOnly]);
|
||||||
|
|
||||||
|
// A row's calls: the mined ones, else whatever the announcement called it.
|
||||||
|
const callsOf = (p: DXped) => (p.calls?.length ? p.calls : p.callsign ? [p.callsign] : []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 gap-3">
|
||||||
|
{/* ── Announcements (ADXO) ── */}
|
||||||
|
<section className="flex flex-col min-h-0 flex-[3] rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||||
|
<span className="text-sm font-semibold">{t('dxp.announced')}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'NG3K ADXO' })}</span>
|
||||||
|
<label className="ml-auto flex items-center gap-1.5 text-[11px] cursor-pointer text-muted-foreground hover:text-foreground">
|
||||||
|
<input type="checkbox" checked={neededOnly}
|
||||||
|
onChange={(e) => { setNeededOnly(e.target.checked); localStorage.setItem('opslog.dxpedNeeded', e.target.checked ? '1' : '0'); }} />
|
||||||
|
{t('dxp.neededOnly')}
|
||||||
|
</label>
|
||||||
|
<Button variant="outline" size="sm" onClick={refresh} disabled={busy}>
|
||||||
|
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('dxp.refresh')}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{err && <div className="px-3 py-2 text-xs text-danger shrink-0">{err}</div>}
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||||
|
{shown.length === 0 && !busy && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.none')}</p>
|
||||||
|
)}
|
||||||
|
{shown.map((p, i) => {
|
||||||
|
const calls = callsOf(p);
|
||||||
|
const badge = CHASE_BADGE[p.status_chase];
|
||||||
|
const allWatched = calls.length > 0 && calls.every((c) => watched.has(c.toUpperCase()));
|
||||||
|
return (
|
||||||
|
<article key={`${p.callsign}-${p.start_date}-${i}`}
|
||||||
|
className={cn('rounded-md border p-2 text-xs',
|
||||||
|
p.status === 'active' ? 'border-success/40 bg-success/5' : 'border-border bg-muted/20')}>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-mono font-bold text-sm text-info">{p.callsign || '—'}</span>
|
||||||
|
<span className="font-medium">{p.dxcc}</span>
|
||||||
|
{p.status === 'active' && (
|
||||||
|
<span className="px-1 py-px rounded text-[10px] font-bold uppercase bg-success-muted text-success-muted-foreground">
|
||||||
|
{t('dxp.onAir')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{badge && (
|
||||||
|
<span className="px-1 py-px rounded text-[10px] font-bold uppercase tracking-wide border"
|
||||||
|
title={p.unconfirmed ? t('dec.unconfTip') : undefined}
|
||||||
|
style={p.unconfirmed
|
||||||
|
? { color: badge.colour, borderColor: badge.colour, borderStyle: 'dashed', opacity: 0.6 }
|
||||||
|
: { color: badge.colour, borderColor: badge.colour }}>
|
||||||
|
{t(badge.label)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-[11px] text-muted-foreground whitespace-nowrap">
|
||||||
|
{p.start_date} → {p.end_date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 flex items-center gap-2 flex-wrap text-[11px] text-muted-foreground">
|
||||||
|
{!!p.bands?.length && <span>{p.bands.join(' · ')}</span>}
|
||||||
|
{!!p.modes?.length && <span className="text-foreground/70">{p.modes.join(' ')}</span>}
|
||||||
|
{p.qsl && <span>QSL: {p.qsl}</span>}
|
||||||
|
{p.source && <span>· {p.source}</span>}
|
||||||
|
</div>
|
||||||
|
{p.operators && <p className="mt-0.5 text-[11px] text-muted-foreground truncate">{p.operators}</p>}
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<Button variant={allWatched ? 'ghost' : 'outline'} size="sm" className="h-6 text-[11px]"
|
||||||
|
disabled={calls.length === 0 || allWatched}
|
||||||
|
onClick={() => addAll(calls)}
|
||||||
|
title={t('dxp.watchTip')}>
|
||||||
|
<Star className={cn('size-3', allWatched && 'fill-current text-warning')} />
|
||||||
|
{allWatched ? t('dxp.watched') : t('dxp.watch')}
|
||||||
|
</Button>
|
||||||
|
{p.link && (
|
||||||
|
<button type="button" onClick={() => BrowserOpenURL(p.link)}
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
|
||||||
|
<ExternalLink className="size-3" /> {t('dxp.open')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── News (DX-World) ── */}
|
||||||
|
<section className="flex flex-col min-h-0 flex-[2] rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||||
|
<span className="text-sm font-semibold">{t('dxp.news')}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'DX-World' })}</span>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||||
|
{news.length === 0 && !busy && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.noNews')}</p>
|
||||||
|
)}
|
||||||
|
{news.map((n, i) => {
|
||||||
|
const newsCalls = n.calls ?? [];
|
||||||
|
const newsAllWatched = newsCalls.length > 0 && newsCalls.every((c) => watched.has(c.toUpperCase()));
|
||||||
|
return (
|
||||||
|
<article key={`${n.link}-${i}`} className="rounded-md border border-border bg-muted/20 p-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{n.image_url && (
|
||||||
|
<img src={n.image_url} alt="" className="size-12 rounded object-cover shrink-0"
|
||||||
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{n.tag && (
|
||||||
|
<span className="px-1 py-px rounded text-[9px] font-bold uppercase bg-primary/15 text-primary">{n.tag}</span>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={() => n.link && BrowserOpenURL(n.link)}
|
||||||
|
className="text-xs font-medium text-left hover:underline">{n.title}</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-[11px] text-muted-foreground line-clamp-3">{n.excerpt}</p>
|
||||||
|
{/* The callsigns are shown, not clicked: watching is the same
|
||||||
|
one button as an announcement, so the gesture is learned
|
||||||
|
once for the whole tab. */}
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 flex-wrap">
|
||||||
|
{n.pub_date && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{new Date(n.pub_date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(n.calls ?? []).map((c) => (
|
||||||
|
<span key={c} className={cn('font-mono text-[10px]',
|
||||||
|
watched.has(c.toUpperCase()) ? 'text-warning' : 'text-info')}>
|
||||||
|
{c}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<Button variant={newsAllWatched ? 'ghost' : 'outline'} size="sm" className="h-6 text-[11px]"
|
||||||
|
disabled={newsCalls.length === 0 || newsAllWatched}
|
||||||
|
onClick={() => addAll(newsCalls)}
|
||||||
|
title={t('dxp.watchTip')}>
|
||||||
|
<Star className={cn('size-3', newsAllWatched && 'fill-current text-warning')} />
|
||||||
|
{newsAllWatched ? t('dxp.watched') : t('dxp.watch')}
|
||||||
|
</Button>
|
||||||
|
{n.link && (
|
||||||
|
<button type="button" onClick={() => BrowserOpenURL(n.link)}
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
|
||||||
|
<ExternalLink className="size-3" /> {t('dxp.open')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,11 +13,16 @@
|
|||||||
// come from the same resolver the cluster uses, so a call means the same thing in
|
// come from the same resolver the cluster uses, so a call means the same thing in
|
||||||
// both panels rather than being judged twice by two rules.
|
// both panels rather than being judged twice by two rules.
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { chaseAllows } from '@/lib/spotDisplay';
|
||||||
|
import { pathBetween } from '@/lib/maidenhead';
|
||||||
|
import { distanceValue, distanceUnit, subscribeDistanceUnit } from '@/lib/units';
|
||||||
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
|
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { SetAutoCallVisible } from '../../wailsjs/go/main/App';
|
||||||
|
import { decoderName } from '@/lib/decoderName';
|
||||||
|
|
||||||
export type Decode = {
|
export type Decode = {
|
||||||
call: string;
|
call: string;
|
||||||
@@ -72,8 +77,14 @@ type StatusEntry = {
|
|||||||
new_pota?: boolean;
|
new_pota?: boolean;
|
||||||
new_pfx?: boolean;
|
new_pfx?: boolean;
|
||||||
new_grid?: boolean;
|
new_grid?: boolean;
|
||||||
|
new_state?: boolean;
|
||||||
|
unconf_status?: boolean;
|
||||||
|
unconf_pfx?: boolean;
|
||||||
|
unconf_cty?: boolean;
|
||||||
|
unconf_state?: boolean;
|
||||||
// "new" = never worked, "unconf" = worked and awaiting a confirmation.
|
// "new" = never worked, "unconf" = worked and awaiting a confirmation.
|
||||||
grid_state?: string;
|
grid_state?: string;
|
||||||
|
state?: string;
|
||||||
grid?: string;
|
grid?: string;
|
||||||
lotw?: boolean;
|
lotw?: boolean;
|
||||||
};
|
};
|
||||||
@@ -88,8 +99,17 @@ interface Props {
|
|||||||
// receiver reported last, which is a coin toss — each pane needs its own.
|
// receiver reported last, which is a coin toss — each pane needs its own.
|
||||||
txStates?: Record<string, TxMsg>;
|
txStates?: Record<string, TxMsg>;
|
||||||
spotStatus: Record<string, StatusEntry>;
|
spotStatus: Record<string, StatusEntry>;
|
||||||
|
// The band the RIG is on, when CAT is connected. Only ever compared with what
|
||||||
|
// the decoder announces — see the drift warning.
|
||||||
|
rigBand?: string;
|
||||||
onCall: (d: Decode) => void;
|
onCall: (d: Decode) => void;
|
||||||
|
// A single click: take the station without transmitting — fill the entry, and
|
||||||
|
// point the panels at it. Absent, a click falls back to onCall.
|
||||||
|
onSelect?: (d: Decode) => void;
|
||||||
myCall?: string;
|
myCall?: string;
|
||||||
|
// The station's own square: distance is measured from it, and without one the
|
||||||
|
// column stays empty rather than guessing.
|
||||||
|
myGrid?: string;
|
||||||
// Drop every decode and transmit message held for this panel. The list is a
|
// Drop every decode and transmit message held for this panel. The list is a
|
||||||
// live view, not data — clearing it costs nothing but the seconds until the
|
// live view, not data — clearing it costs nothing but the seconds until the
|
||||||
// next period lands.
|
// next period lands.
|
||||||
@@ -105,6 +125,15 @@ interface Props {
|
|||||||
// machine off is not something to go hunting through a settings tree for.
|
// machine off is not something to go hunting through a settings tree for.
|
||||||
autoCallOn?: boolean;
|
autoCallOn?: boolean;
|
||||||
onToggleAutoCall?: () => void;
|
onToggleAutoCall?: () => void;
|
||||||
|
// The engine's own account of what it is doing, straight from the backend.
|
||||||
|
autoCall?: { target: string; waiting: string; calls: number; max: number; misses: number; max_miss: number; stopped: boolean; reason: string };
|
||||||
|
// The chase list, here as well as in Preferences: naming the station you are
|
||||||
|
// waiting for is done WHILE watching the band, not in a settings tree.
|
||||||
|
autoCallOnly?: string;
|
||||||
|
onSetAutoCallOnly?: (list: string) => void;
|
||||||
|
// The watch list, as PATTERNS (VK9*, 3Y0J). A decode of one is worth saying
|
||||||
|
// so where the operator is reading the band, not only in the watchlist tab.
|
||||||
|
watchlist?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// The "new" categories, as toggle badges — the same idea and the same colours as
|
// The "new" categories, as toggle badges — the same idea and the same colours as
|
||||||
@@ -112,7 +141,10 @@ interface Props {
|
|||||||
//
|
//
|
||||||
// All off means no filtering at all: this is a decode LOG first, and a panel
|
// All off means no filtering at all: this is a decode LOG first, and a panel
|
||||||
// that starts by hiding most of the band would be lying about what is on it.
|
// that starts by hiding most of the band would be lying about what is on it.
|
||||||
type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty';
|
type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty' | 'state';
|
||||||
|
|
||||||
|
// The seven, in the order an operator reads them.
|
||||||
|
const CONTINENTS = ['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'];
|
||||||
|
|
||||||
const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
|
const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
|
||||||
{ key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' },
|
{ key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' },
|
||||||
@@ -123,6 +155,7 @@ const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
|
|||||||
{ key: 'grid', label: 'dec.bgGrid', colour: markerColour('new_grid') },
|
{ key: 'grid', label: 'dec.bgGrid', colour: markerColour('new_grid') },
|
||||||
{ key: 'pfx', label: 'dec.bgPfx', colour: markerColour('new_pfx') },
|
{ key: 'pfx', label: 'dec.bgPfx', colour: markerColour('new_pfx') },
|
||||||
{ key: 'cty', label: 'dec.bgCounty', colour: markerColour('new_county') },
|
{ key: 'cty', label: 'dec.bgCounty', colour: markerColour('new_county') },
|
||||||
|
{ key: 'state', label: 'dec.bgState', colour: markerColour('new_state') },
|
||||||
];
|
];
|
||||||
|
|
||||||
// catsOf lists everything a decode is new for. A station can be several at once
|
// catsOf lists everything a decode is new for. A station can be several at once
|
||||||
@@ -143,9 +176,25 @@ function catsOf(e: StatusEntry | undefined): Set<NewCat> {
|
|||||||
if (e.new_grid) out.add('grid');
|
if (e.new_grid) out.add('grid');
|
||||||
if (e.new_pfx) out.add('pfx');
|
if (e.new_pfx) out.add('pfx');
|
||||||
if (e.new_county) out.add('cty');
|
if (e.new_county) out.add('cty');
|
||||||
|
if (e.new_state) out.add('state');
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isWatched applies the watch list's own rule — a trailing "*" is a prefix,
|
||||||
|
// anything else is the whole callsign — so a decode is judged here exactly as
|
||||||
|
// the backend judges a spot. Two rules for one list is how a badge and an alert
|
||||||
|
// start disagreeing about the same station.
|
||||||
|
function isWatched(call: string, patterns: string[] | undefined): boolean {
|
||||||
|
if (!patterns || patterns.length === 0 || !call) return false;
|
||||||
|
const c = call.toUpperCase();
|
||||||
|
for (const raw of patterns) {
|
||||||
|
const p = (raw ?? '').toUpperCase().trim();
|
||||||
|
if (!p) continue;
|
||||||
|
if (p.endsWith('*') ? c.startsWith(p.slice(0, -1)) : c === p) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const CAT_KEY = 'opslog.decodeCats';
|
const CAT_KEY = 'opslog.decodeCats';
|
||||||
const SPLIT_KEY = 'opslog.decodeSplit';
|
const SPLIT_KEY = 'opslog.decodeSplit';
|
||||||
const FILTER_KEY = 'opslog.decodeFilters';
|
const FILTER_KEY = 'opslog.decodeFilters';
|
||||||
@@ -227,9 +276,14 @@ const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
|
|||||||
// One declaration per column, in display order: the header, the widths and the
|
// One declaration per column, in display order: the header, the widths and the
|
||||||
// resize handles all read from this, so a column cannot be resized in the header
|
// resize handles all read from this, so a column cannot be resized in the header
|
||||||
// and stay the old width in the body.
|
// and stay the old width in the body.
|
||||||
type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'country' | 'status';
|
type ColKey = 'time' | 'rx' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'dist' | 'state' | 'country' | 'status';
|
||||||
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
||||||
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
|
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
|
||||||
|
// WHICH RECEIVER heard it. Shown only while more than one is feeding, and
|
||||||
|
// that is the case it exists for: two decoders on one band send the same
|
||||||
|
// stations twice, each with its own SNR and DT, and a merged list gave no way
|
||||||
|
// at all to tell a second receiver from a duplicate.
|
||||||
|
{ key: 'rx', tkey: 'dec.colRx', def: 74, min: 44 },
|
||||||
{ key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 },
|
{ key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 },
|
||||||
{ key: 'dt', tkey: 'dec.colDt', def: 44, min: 32 },
|
{ key: 'dt', tkey: 'dec.colDt', def: 44, min: 32 },
|
||||||
{ key: 'freq', tkey: 'dec.colFreq', def: 56, min: 40 },
|
{ key: 'freq', tkey: 'dec.colFreq', def: 56, min: 40 },
|
||||||
@@ -241,9 +295,31 @@ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
|||||||
// The grid was carried and shown nowhere: it drives the NEW GRID badge, and
|
// The grid was carried and shown nowhere: it drives the NEW GRID badge, and
|
||||||
// an operator chasing squares could see the verdict but never the square.
|
// an operator chasing squares could see the verdict but never the square.
|
||||||
{ key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 },
|
{ key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 },
|
||||||
|
// For the WAS chasers: the badge carries the two letters, the name spells
|
||||||
|
// them out — "SD" alone is a quiz for a European.
|
||||||
|
// Next to the square it is computed from. A four-character grid is a square
|
||||||
|
// tens of kilometres wide, so this is rounded to whole units and never
|
||||||
|
// pretends to more: it answers "is that station across the pond or across the
|
||||||
|
// valley", which is what decides whether the report is worth anything.
|
||||||
|
{ key: 'dist', tkey: 'dec.colDist', def: 70, min: 46 },
|
||||||
|
{ key: 'state', tkey: 'dec.colState', def: 120, min: 56 },
|
||||||
{ key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
|
{ key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
|
||||||
{ key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 },
|
{ key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 },
|
||||||
];
|
];
|
||||||
|
const US_STATES: Record<string, string> = {
|
||||||
|
AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California',
|
||||||
|
CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', FL: 'Florida', GA: 'Georgia',
|
||||||
|
HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa',
|
||||||
|
KS: 'Kansas', KY: 'Kentucky', LA: 'Louisiana', ME: 'Maine', MD: 'Maryland',
|
||||||
|
MA: 'Massachusetts', MI: 'Michigan', MN: 'Minnesota', MS: 'Mississippi',
|
||||||
|
MO: 'Missouri', MT: 'Montana', NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire',
|
||||||
|
NJ: 'New Jersey', NM: 'New Mexico', NY: 'New York', NC: 'North Carolina',
|
||||||
|
ND: 'North Dakota', OH: 'Ohio', OK: 'Oklahoma', OR: 'Oregon', PA: 'Pennsylvania',
|
||||||
|
RI: 'Rhode Island', SC: 'South Carolina', SD: 'South Dakota', TN: 'Tennessee',
|
||||||
|
TX: 'Texas', UT: 'Utah', VT: 'Vermont', VA: 'Virginia', WA: 'Washington',
|
||||||
|
WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming', DC: 'District of Columbia',
|
||||||
|
};
|
||||||
|
|
||||||
const COL_MAX = 600;
|
const COL_MAX = 600;
|
||||||
const COLW_KEY = 'opslog.decodeColWidths';
|
const COLW_KEY = 'opslog.decodeColWidths';
|
||||||
|
|
||||||
@@ -308,14 +384,33 @@ function ColResizer({ onResize, onReset }: { onResize: (dx: number) => void; onR
|
|||||||
//
|
//
|
||||||
// Colours match the cluster list and the band map — the same fact must not be
|
// Colours match the cluster list and the band map — the same fact must not be
|
||||||
// amber in one panel and green in the next.
|
// amber in one panel and green in the next.
|
||||||
const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
|
const ENTITY_BADGE: Record<string, { label: string; cls: string; colour: string }> = {
|
||||||
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground' },
|
// colour is the category's own hue, for the UNCONFIRMED rendering: the
|
||||||
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground' },
|
// filled cls puts light text on a filled chip, and dimming that to a
|
||||||
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground' },
|
// transparent background left light-on-nothing — an invisible badge.
|
||||||
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground' },
|
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground', colour: 'var(--success)' },
|
||||||
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground' },
|
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground', colour: 'var(--warning)' },
|
||||||
|
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground', colour: 'var(--info)' },
|
||||||
|
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground', colour: 'var(--caution)' },
|
||||||
|
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground', colour: 'var(--muted-foreground)' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// entityBadgesFor turns a status into the badges that describe it.
|
||||||
|
//
|
||||||
|
// A LIST, because "new-band-mode" is two facts at once — new on this band and
|
||||||
|
// new in this mode — and the map above has an entry for neither. It was read
|
||||||
|
// with a single lookup, which returned nothing for that status: those rows
|
||||||
|
// passed the BAND and MODE filters and then showed no reason for being there,
|
||||||
|
// which is precisely what was reported. catsOf has always split the status into
|
||||||
|
// its two categories; this is the same split, on the screen.
|
||||||
|
function entityBadgesFor(status: string): { label: string; cls: string; colour: string }[] {
|
||||||
|
if (status === 'new-band-mode') {
|
||||||
|
return [ENTITY_BADGE['new-band'], ENTITY_BADGE['new-mode']];
|
||||||
|
}
|
||||||
|
const one = ENTITY_BADGE[status];
|
||||||
|
return one ? [one] : [];
|
||||||
|
}
|
||||||
|
|
||||||
// The orthogonal ones: a station already worked for its entity can still be a
|
// The orthogonal ones: a station already worked for its entity can still be a
|
||||||
// new grid, a new prefix or a park never logged.
|
// new grid, a new prefix or a park never logged.
|
||||||
//
|
//
|
||||||
@@ -327,6 +422,7 @@ const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
|
|||||||
const EXTRA_BADGES: { key: keyof StatusEntry; marker: SpotMarkerKey; label: string }[] = [
|
const EXTRA_BADGES: { key: keyof StatusEntry; marker: SpotMarkerKey; label: string }[] = [
|
||||||
{ key: 'new_pota', marker: 'new_pota', label: 'dec.bgPota' },
|
{ key: 'new_pota', marker: 'new_pota', label: 'dec.bgPota' },
|
||||||
{ key: 'new_grid', marker: 'new_grid', label: 'dec.bgGrid' },
|
{ key: 'new_grid', marker: 'new_grid', label: 'dec.bgGrid' },
|
||||||
|
{ key: 'new_state', marker: 'new_state', label: 'dec.bgState' },
|
||||||
{ key: 'new_pfx', marker: 'new_pfx', label: 'dec.bgPfx' },
|
{ key: 'new_pfx', marker: 'new_pfx', label: 'dec.bgPfx' },
|
||||||
{ key: 'new_county', marker: 'new_county', label: 'dec.bgCounty' },
|
{ key: 'new_county', marker: 'new_county', label: 'dec.bgCounty' },
|
||||||
];
|
];
|
||||||
@@ -360,16 +456,31 @@ function periodLabel(ms: number, trSec: number): string {
|
|||||||
// read "CQ CQ PE1NAO JO32" — the badge and the message's own first word saying
|
// read "CQ CQ PE1NAO JO32" — the badge and the message's own first word saying
|
||||||
// the same thing twice. Highlighting the word already in the line keeps the
|
// the same thing twice. Highlighting the word already in the line keeps the
|
||||||
// scannability and drops the stutter.
|
// scannability and drops the stutter.
|
||||||
function renderMsg(msg: string, me: string, calling: string) {
|
function renderMsg(msg: string, me: string, calling: string, toMe: boolean) {
|
||||||
if (!msg) return null;
|
if (!msg) return null;
|
||||||
|
// THE WHOLE LINE, when it is addressed to YOU.
|
||||||
|
//
|
||||||
|
// Token colouring is for reading the band — it picks your call out of a wall
|
||||||
|
// of other people's traffic. A message sent TO you is a different question:
|
||||||
|
// not "is my call in there somewhere" but "what did he just send me", read
|
||||||
|
// from the far side of the shack, so the whole line carries the colour.
|
||||||
|
//
|
||||||
|
// Only that case. The station you are calling also transmits to everybody
|
||||||
|
// else — "LA8WRA LA1RAU/P +00" is your target reporting to another caller —
|
||||||
|
// and setting those lines in the same strong colour said you were in a QSO
|
||||||
|
// you were not in. Its callsign is picked out by the token pass below, which
|
||||||
|
// is what "he is on the air, working somebody else" should look like.
|
||||||
|
if (toMe) {
|
||||||
|
return <span className="font-bold text-success">{msg}</span>;
|
||||||
|
}
|
||||||
// Split on whitespace and colour the tokens that matter, rather than the
|
// Split on whitespace and colour the tokens that matter, rather than the
|
||||||
// whole line: an operator scanning a slot is looking for their own call in
|
// whole line: an operator scanning a slot is looking for their own call in
|
||||||
// the first position (someone answering) and for the station being called.
|
// the first position (someone answering) and for the station being called.
|
||||||
const parts = msg.split(/(s+)/);
|
const parts = msg.split(/(\s+)/);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{parts.map((tok, i) => {
|
{parts.map((tok, i) => {
|
||||||
if (/^s+$/.test(tok)) return tok;
|
if (/^\s+$/.test(tok)) return tok;
|
||||||
const bare = tok.replace(/[<>]/g, '').toUpperCase();
|
const bare = tok.replace(/[<>]/g, '').toUpperCase();
|
||||||
if (i === 0 && /^CQ$/i.test(tok)) return <span key={i} className="font-bold text-success">{tok.toUpperCase()}</span>;
|
if (i === 0 && /^CQ$/i.test(tok)) return <span key={i} className="font-bold text-success">{tok.toUpperCase()}</span>;
|
||||||
if (me && bare === me) return <span key={i} className="font-bold text-success">{tok}</span>;
|
if (me && bare === me) return <span key={i} className="font-bold text-success">{tok}</span>;
|
||||||
@@ -388,7 +499,7 @@ function renderMsg(msg: string, me: string, calling: string) {
|
|||||||
//
|
//
|
||||||
// It is the one moving thing on the panel, and it answers the question an
|
// It is the one moving thing on the panel, and it answers the question an
|
||||||
// operator actually has between overs: how long until the next batch.
|
// operator actually has between overs: how long until the next batch.
|
||||||
function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
|
function PeriodClock({ trSec, mode, tx }: { trSec: number; mode?: string; tx?: boolean }) {
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 100 ms: smooth enough for a bar that fills in three and three quarter
|
// 100 ms: smooth enough for a bar that fills in three and three quarter
|
||||||
@@ -404,19 +515,26 @@ function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
|
|||||||
// The last fifth of a slot is when a decode is imminent and an operator
|
// The last fifth of a slot is when a decode is imminent and an operator
|
||||||
// deciding whether to answer has run out of time to think.
|
// deciding whether to answer has run out of time to think.
|
||||||
const closing = left <= trSec / 5;
|
const closing = left <= trSec / 5;
|
||||||
|
// TRANSMITTING outranks both. The bar is the one thing on this screen that
|
||||||
|
// moves continuously, so it is what the eye is already on — and "am I on the
|
||||||
|
// air" is the state worth reading from across the room. Red, and it stays red
|
||||||
|
// for the whole over rather than turning amber near the end of it.
|
||||||
|
const tone = tx ? 'danger' : closing ? 'warning' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2 shrink-0" title={mode ? `${mode} · ${trSec}s` : `${trSec}s`}>
|
<span className="flex items-center gap-2 shrink-0" title={mode ? `${mode} · ${trSec}s` : `${trSec}s`}>
|
||||||
<Timer className={cn('size-4', closing ? 'text-warning' : 'text-muted-foreground')} />
|
<Timer className={cn('size-4',
|
||||||
|
tone === 'danger' ? 'text-danger' : tone === 'warning' ? 'text-warning' : 'text-muted-foreground')} />
|
||||||
<span className="relative h-1.5 w-24 rounded-full bg-muted overflow-hidden">
|
<span className="relative h-1.5 w-24 rounded-full bg-muted overflow-hidden">
|
||||||
<span
|
<span
|
||||||
className={cn('absolute inset-y-0 left-0 rounded-full transition-[width] duration-100 ease-linear',
|
className={cn('absolute inset-y-0 left-0 rounded-full transition-[width] duration-100 ease-linear',
|
||||||
closing ? 'bg-warning' : 'bg-primary')}
|
tone === 'danger' ? 'bg-danger' : tone === 'warning' ? 'bg-warning' : 'bg-primary')}
|
||||||
style={{ width: `${pct}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className={cn('font-mono text-sm tabular-nums w-10 text-right',
|
<span className={cn('font-mono text-sm tabular-nums w-10 text-right',
|
||||||
closing ? 'text-warning font-semibold' : 'text-muted-foreground')}>
|
tone === 'danger' ? 'text-danger font-semibold'
|
||||||
|
: tone === 'warning' ? 'text-warning font-semibold' : 'text-muted-foreground')}>
|
||||||
{left.toFixed(1)}
|
{left.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@@ -482,21 +600,29 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
|||||||
// the same way it was grouped.
|
// the same way it was grouped.
|
||||||
tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period),
|
tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period),
|
||||||
tx: g.tx,
|
tx: g.tx,
|
||||||
// Strongest first inside a period: the eye should land on what is
|
// ARRIVAL order inside a period, per the operator: it mirrors the
|
||||||
// workable, and time within a slot means nothing — they were all
|
// decoder's own window line for line, which makes the two screens
|
||||||
// transmitting simultaneously.
|
// comparable at a glance — the strongest-first sort scrambled that
|
||||||
decodes: g.decodes.sort((x, y) => y.snr - x.snr),
|
// correspondence, and SNR is right there in its column anyway.
|
||||||
|
decodes: g.decodes,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: Props) {
|
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
// Column widths, dragged in the header and shared by every row. Persisted
|
// Column widths, dragged in the header and shared by every row. Persisted
|
||||||
// through writeUiPref (not raw localStorage) so the layout travels with data/
|
// through writeUiPref (not raw localStorage) so the layout travels with data/
|
||||||
// like every other portable preference.
|
// like every other portable preference.
|
||||||
const [colw, setColw] = useState<ColWidths>(loadWidths);
|
const [colw, setColw] = useState<ColWidths>(loadWidths);
|
||||||
const template = useMemo(() => COLS.map((c) => `${colw[c.key]}px`).join(' '), [colw]);
|
// The chase list as TYPED. Re-seeded whenever the stored value changes —
|
||||||
const tableW = useMemo(() => COLS.reduce((s, c) => s + colw[c.key], 0), [colw]);
|
// from Preferences, or from another window — but never while the box has the
|
||||||
|
// focus, or a status arriving mid-word would rewrite what is being typed.
|
||||||
|
const [onlyText, setOnlyText] = useState(autoCallOnly ?? '');
|
||||||
|
useEffect(() => {
|
||||||
|
const el = document.activeElement as HTMLElement | null;
|
||||||
|
if (el && el.tagName === 'INPUT' && el.getAttribute('placeholder') === t('dec.chasePh')) return;
|
||||||
|
setOnlyText(autoCallOnly ?? '');
|
||||||
|
}, [autoCallOnly, t]);
|
||||||
const setColWidth = (key: ColKey, px: number) => {
|
const setColWidth = (key: ColKey, px: number) => {
|
||||||
const col = COLS.find((c) => c.key === key)!;
|
const col = COLS.find((c) => c.key === key)!;
|
||||||
const w = Math.min(COL_MAX, Math.max(col.min, Math.round(px)));
|
const w = Math.min(COL_MAX, Math.max(col.min, Math.round(px)));
|
||||||
@@ -547,6 +673,21 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
|
|
||||||
// The mode currently on the air, for the slot clock. The newest decode knows
|
// The mode currently on the air, for the slot clock. The newest decode knows
|
||||||
// best; between overs the transmit state still does.
|
// best; between overs the transmit state still does.
|
||||||
|
// A decoder that has lost its CAT link keeps announcing the last dial
|
||||||
|
// frequency it knew, and every decode after that carries a stale band. Nothing
|
||||||
|
// downstream can tell: the entity verdicts, the band filter and the FT map all
|
||||||
|
// believe what the decoder said, and an operator ends up reading NEW BAND for a
|
||||||
|
// band they are not on. (Seen for real: MSHV lost CAT, kept saying 80 m, and
|
||||||
|
// Korea showed as a new band because on 80 m it would have been.)
|
||||||
|
//
|
||||||
|
// Said, not decided. Using the rig's band instead would be wrong for anyone
|
||||||
|
// decoding a second receiver on another band, and a warning costs that setup
|
||||||
|
// nothing but a line it can read past.
|
||||||
|
const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : '';
|
||||||
|
const bandDrift = !!rigBand && !!decoderBand
|
||||||
|
&& rigBand.toLowerCase() !== decoderBand.toLowerCase();
|
||||||
|
const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : '';
|
||||||
|
|
||||||
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
|
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
|
||||||
const liveTr = trSeconds(liveMode, decodes.length ? decodes[decodes.length - 1].tr_period : undefined);
|
const liveTr = trSeconds(liveMode, decodes.length ? decodes[decodes.length - 1].tr_period : undefined);
|
||||||
|
|
||||||
@@ -558,6 +699,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
// opens with our callsign, sometimes bracketed when the sender compressed a
|
// opens with our callsign, sometimes bracketed when the sender compressed a
|
||||||
// non-standard call.
|
// non-standard call.
|
||||||
const me = (myCall ?? '').toUpperCase();
|
const me = (myCall ?? '').toUpperCase();
|
||||||
|
const [, bumpUnit] = useState(0);
|
||||||
|
useEffect(() => subscribeDistanceUnit(() => bumpUnit((n) => n + 1)), []);
|
||||||
const calling = (txState?.dx_call ?? '').toUpperCase();
|
const calling = (txState?.dx_call ?? '').toUpperCase();
|
||||||
const answersMe = (msg?: string): boolean => {
|
const answersMe = (msg?: string): boolean => {
|
||||||
if (!me || !msg) return false;
|
if (!me || !msg) return false;
|
||||||
@@ -583,27 +726,31 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
// dropdowns were pure furniture for most operators; they appear the day a
|
// dropdowns were pure furniture for most operators; they appear the day a
|
||||||
// second instance puts a second band on the link, which is the only day they
|
// second instance puts a second band on the link, which is the only day they
|
||||||
// mean anything.
|
// mean anything.
|
||||||
const { bands, modes, conts, instances } = useMemo(() => {
|
const { bands, modes, instances } = useMemo(() => {
|
||||||
const b = new Set<string>(), m = new Set<string>(), c = new Set<string>(), i = new Set<string>();
|
const b = new Set<string>(), m = new Set<string>(), i = new Set<string>();
|
||||||
for (const d of decodes) {
|
for (const d of decodes) {
|
||||||
if (d.band) b.add(d.band);
|
if (d.band) b.add(d.band);
|
||||||
if (d.mode) m.add(d.mode);
|
if (d.mode) m.add(d.mode);
|
||||||
if (d.instance) i.add(d.instance);
|
if (d.instance) i.add(d.instance);
|
||||||
const ct = statusOf(d)?.continent;
|
|
||||||
if (ct) c.add(ct);
|
|
||||||
}
|
}
|
||||||
return { bands: [...b].sort(), modes: [...m].sort(), conts: [...c].sort(), instances: [...i].sort() };
|
return { bands: [...b].sort(), modes: [...m].sort(), instances: [...i].sort() };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [decodes, spotStatus]);
|
}, [decodes, spotStatus]);
|
||||||
|
|
||||||
// The chips are the continents on the feed — a selector with nothing to choose
|
// The receiver column is dead weight with one decoder — which is nearly
|
||||||
// is furniture — PLUS anything currently selected. Without that second half a
|
// everybody — so it is not there at all until a second one starts feeding.
|
||||||
// filter can strand itself: pick AF, the last African station stops decoding,
|
const cols = useMemo(() => COLS.filter((c) => c.key !== 'rx' || (instances.length > 1 && !splitByInstance)),
|
||||||
// and the list empties with no chip left to switch it back off.
|
[instances.length, splitByInstance]);
|
||||||
const contChips = useMemo(
|
const template = useMemo(() => cols.map((c) => `${colw[c.key]}px`).join(' '), [cols, colw]);
|
||||||
() => [...new Set([...conts, ...contList])].sort(),
|
const tableW = useMemo(() => cols.reduce((s, c) => s + colw[c.key], 0), [cols, colw]);
|
||||||
[conts, contList],
|
// All seven, always, in their usual order.
|
||||||
);
|
//
|
||||||
|
// The chips used to be built from the continents ON the feed, which read as a
|
||||||
|
// list that reshuffled itself every period: a chip appeared when the first
|
||||||
|
// Asian station decoded and moved everything sideways under the pointer. A
|
||||||
|
// fixed row can be aimed at — you learn where OC is and it stays there — and
|
||||||
|
// it also says what the filter can do before anything has been heard.
|
||||||
|
const contChips = CONTINENTS;
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.trim().toUpperCase();
|
const q = search.trim().toUpperCase();
|
||||||
@@ -629,6 +776,22 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [decodes, spotStatus, cqOnly, lotwOnly, cats, bandSel, modeSel, contSel, minSnr, search]);
|
}, [decodes, spotStatus, cqOnly, lotwOnly, cats, bandSel, modeSel, contSel, minSnr, search]);
|
||||||
|
// What this panel is SHOWING, published to the auto-call engine.
|
||||||
|
//
|
||||||
|
// The filters are the operator's control over the transmitter as well as
|
||||||
|
// over the list: a station filtered off the screen is not called. The panel
|
||||||
|
// sends the callsigns rather than the filter settings, so there is one
|
||||||
|
// definition of "shown" and not two — the engine cannot disagree with what
|
||||||
|
// is in front of the operator.
|
||||||
|
useEffect(() => {
|
||||||
|
const calls = [...new Set(filtered.map((d) => (d.call ?? '').toUpperCase()).filter(Boolean))];
|
||||||
|
SetAutoCallVisible(calls, true).catch(() => {});
|
||||||
|
}, [filtered]);
|
||||||
|
// Closed, it publishes nothing: filters that are not on the screen cannot
|
||||||
|
// silence the engine behind the operator's back.
|
||||||
|
useEffect(() => () => { SetAutoCallVisible([], false).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Group into periods, newest first, and drop the operator's transmissions into
|
// Group into periods, newest first, and drop the operator's transmissions into
|
||||||
// the slot they went out in.
|
// the slot they went out in.
|
||||||
@@ -644,7 +807,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
}
|
}
|
||||||
return instances.map((inst) => ({
|
return instances.map((inst) => ({
|
||||||
key: inst,
|
key: inst,
|
||||||
label: inst,
|
// What the program is called, not the id it announces — see decoderName.
|
||||||
|
label: decoderName(inst),
|
||||||
tx: txStates?.[inst],
|
tx: txStates?.[inst],
|
||||||
periods: buildPeriods(
|
periods: buildPeriods(
|
||||||
filtered.filter((d) => (d.instance ?? '') === inst),
|
filtered.filter((d) => (d.instance ?? '') === inst),
|
||||||
@@ -684,7 +848,23 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
{/* The slot clock. Taken from the newest decode's mode, falling back to
|
{/* The slot clock. Taken from the newest decode's mode, falling back to
|
||||||
what the transmit state reports, so it is right the moment anything
|
what the transmit state reports, so it is right the moment anything
|
||||||
is heard and keeps running when the band goes quiet. */}
|
is heard and keeps running when the band goes quiet. */}
|
||||||
<PeriodClock trSec={liveTr} mode={liveMode} />
|
{/* Any receiver on the air colours it: with two decoders the shared
|
||||||
|
txState is whichever reported last, and "somebody here is
|
||||||
|
transmitting" is what the bar has to say. */}
|
||||||
|
<PeriodClock trSec={liveTr} mode={liveMode}
|
||||||
|
tx={!!txState?.transmitting || Object.values(txStates ?? {}).some((s) => s?.transmitting)} />
|
||||||
|
{bandDrift && (
|
||||||
|
<span
|
||||||
|
title={t('dec.bandDriftTip')}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-warning px-2 py-0.5 text-[11px] font-semibold text-warning">
|
||||||
|
<AlertTriangle className="size-3.5" />
|
||||||
|
{t('dec.bandDrift', {
|
||||||
|
app: decoderName(driftInstance) || t('dec.bandDriftApp'),
|
||||||
|
dec: decoderBand.toUpperCase(),
|
||||||
|
rig: (rigBand ?? '').toUpperCase(),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="w-px h-5 bg-border/60 mx-1" />
|
<span className="w-px h-5 bg-border/60 mx-1" />
|
||||||
|
|
||||||
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly(!cqOnly)}>
|
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly(!cqOnly)}>
|
||||||
@@ -697,7 +877,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
{/* Per-category badges, in the colours of the flags they select — the
|
{/* Per-category badges, in the colours of the flags they select — the
|
||||||
same vocabulary as the Chase New panel. */}
|
same vocabulary as the Chase New panel. */}
|
||||||
<span className="flex items-center gap-1 pl-1 border-l border-border/60 ml-1" title={t('dec.catsHint')}>
|
<span className="flex items-center gap-1 pl-1 border-l border-border/60 ml-1" title={t('dec.catsHint')}>
|
||||||
{NEW_CATS.map((c) => {
|
{NEW_CATS.filter((c) => chaseAllows(c.key)).map((c) => {
|
||||||
const on = cats.has(c.key);
|
const on = cats.has(c.key);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -811,6 +991,62 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
filter. Halt goes LAST — it is the one that must be findable without
|
filter. Halt goes LAST — it is the one that must be findable without
|
||||||
reading, and the end of the row is the one position that never moves
|
reading, and the end of the row is the one position that never moves
|
||||||
as filters come and go. */}
|
as filters come and go. */}
|
||||||
|
{/* Auto-call. Deliberately next to Halt: the two belong together, and
|
||||||
|
what it is doing right now — which station, how many calls of how
|
||||||
|
many — is on the button itself, because a thing that keys the
|
||||||
|
transmitter must never be a switch with no readout. */}
|
||||||
|
{onToggleAutoCall && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleAutoCall}
|
||||||
|
title={autoCall?.stopped ? t('dec.autoStoppedTip') : t('dec.autoCallTip')}
|
||||||
|
className={cn('h-8 px-2.5 rounded-lg text-sm inline-flex items-center gap-1.5 border font-medium',
|
||||||
|
!autoCallOn
|
||||||
|
? 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||||
|
: autoCall?.stopped
|
||||||
|
? 'border-warning bg-warning text-warning-foreground'
|
||||||
|
: 'border-success bg-success text-success-foreground')}
|
||||||
|
>
|
||||||
|
<Bot className="size-3.5" />
|
||||||
|
{t('dec.autoCall')}
|
||||||
|
{autoCallOn && autoCall?.target && (
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{autoCall.target} {autoCall.calls}/{autoCall.max}
|
||||||
|
{autoCall.misses > 0 ? ` ·${autoCall.misses}/${autoCall.max_miss}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Wanted, decoded, and in a QSO with somebody else. Holding fire
|
||||||
|
looks exactly like having nothing to do, and the operator had no
|
||||||
|
way to tell them apart. */}
|
||||||
|
{autoCallOn && !autoCall?.target && autoCall?.waiting && (
|
||||||
|
<span className="font-mono text-xs animate-pulse" title={t('dec.autoWaitTip', { call: autoCall.waiting })}>
|
||||||
|
{autoCall.waiting} ⏳
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* The chase list. Raw text while typing, committed on blur or Enter:
|
||||||
|
the stored value is upper-cased and trimmed, and binding the box to
|
||||||
|
that makes the space bar look dead — in a field whose whole purpose
|
||||||
|
is a list separated by spaces. */}
|
||||||
|
{onSetAutoCallOnly && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={onlyText}
|
||||||
|
onChange={(e) => setOnlyText(e.target.value)}
|
||||||
|
onBlur={() => { if (onlyText.toUpperCase() !== (autoCallOnly ?? '')) onSetAutoCallOnly(onlyText); }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||||
|
if (e.key === 'Escape') setOnlyText(autoCallOnly ?? '');
|
||||||
|
}}
|
||||||
|
placeholder={t('dec.chasePh')}
|
||||||
|
title={t('dec.chaseTip')}
|
||||||
|
className={cn('h-8 w-44 rounded-lg border px-2 text-sm font-mono uppercase bg-background',
|
||||||
|
(autoCallOnly ?? '').trim()
|
||||||
|
? 'border-primary text-foreground'
|
||||||
|
: 'border-border text-muted-foreground')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{onHalt && (
|
{onHalt && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -877,7 +1113,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
{txState.band && <span className="text-xs text-muted-foreground shrink-0">{txState.band}</span>}
|
{txState.band && <span className="text-xs text-muted-foreground shrink-0">{txState.band}</span>}
|
||||||
{txState.instance && instances.length > 1 && (
|
{txState.instance && instances.length > 1 && (
|
||||||
<span className="text-xs text-muted-foreground shrink-0">{txState.instance}</span>
|
<span className="text-xs text-muted-foreground shrink-0">{decoderName(txState.instance)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -936,17 +1172,17 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
<div className="shrink-0 border-b border-border bg-background overflow-hidden">
|
<div className="shrink-0 border-b border-border bg-background overflow-hidden">
|
||||||
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}
|
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}
|
||||||
style={{ gridTemplateColumns: template, width: tableW }}>
|
style={{ gridTemplateColumns: template, width: tableW }}>
|
||||||
{COLS.map((c, i) => (
|
{cols.map((c, i) => (
|
||||||
<span key={c.key}
|
<span key={c.key}
|
||||||
// Not CELL_LAST for the final column: its overflow-hidden would
|
// Not CELL_LAST for the final column: its overflow-hidden would
|
||||||
// clip that column's own resize handle.
|
// clip that column's own resize handle.
|
||||||
className={cn('relative flex items-center min-w-0 px-2',
|
className={cn('relative flex items-center min-w-0 px-2',
|
||||||
i < COLS.length - 1 && 'border-r border-border/30',
|
i < cols.length - 1 && 'border-r border-border/30',
|
||||||
// The three numeric columns label their own right edge, where the
|
// The three numeric columns label their own right edge, where the
|
||||||
// figures are.
|
// figures are.
|
||||||
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq') && 'justify-end')}
|
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq' || c.key === 'dist') && 'justify-end')}
|
||||||
title={c.key === 'dt' ? t('dec.colDtTitle') : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
|
title={c.key === 'dt' ? t('dec.colDtTitle') : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
|
||||||
<span className="truncate">{t(c.tkey)}</span>
|
<span className="truncate">{c.key === 'dist' ? `${t(c.tkey)} (${distanceUnit()})` : t(c.tkey)}</span>
|
||||||
<ColResizer
|
<ColResizer
|
||||||
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
|
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
|
||||||
onReset={() => setColWidth(c.key, c.def)}
|
onReset={() => setColWidth(c.key, c.def)}
|
||||||
@@ -1004,10 +1240,10 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
{g.decodes.map((d, i) => {
|
{g.decodes.map((d, i) => {
|
||||||
const e = statusOf(d);
|
const e = statusOf(d);
|
||||||
const st = e?.status && e.status !== 'worked' ? e.status : '';
|
const st = e?.status && e.status !== 'worked' ? e.status : '';
|
||||||
const entity = st ? ENTITY_BADGE[st] : undefined;
|
const entities = st ? entityBadgesFor(st) : [];
|
||||||
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key]);
|
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key] && chaseAllows(b.key as string));
|
||||||
const mine = !!me && d.call === me;
|
const mine = !!me && d.call === me;
|
||||||
const hot = !!entity || extras.length > 0;
|
const hot = entities.length > 0 || extras.length > 0;
|
||||||
// Someone answering us outranks everything else on the screen.
|
// Someone answering us outranks everything else on the screen.
|
||||||
const replying = answersMe(d.msg);
|
const replying = answersMe(d.msg);
|
||||||
// The station we are calling, so it can be picked out of a slot
|
// The station we are calling, so it can be picked out of a slot
|
||||||
@@ -1017,11 +1253,19 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
<button
|
<button
|
||||||
key={`${d.call}-${d.freq_hz}-${i}`}
|
key={`${d.call}-${d.freq_hz}-${i}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onCall(d)}
|
// ONE click selects, TWO transmit — the cluster's rule, and
|
||||||
|
// the only safe one here: a single click used to hand the
|
||||||
|
// decode straight to WSJT-X as a Reply, so brushing a row
|
||||||
|
// while reading the band started calling a station.
|
||||||
|
onClick={() => (onSelect ?? onCall)(d)}
|
||||||
|
onDoubleClick={() => onCall(d)}
|
||||||
title={t('dec.callTitle', { call: d.call })}
|
title={t('dec.callTitle', { call: d.call })}
|
||||||
style={{ gridTemplateColumns: template, width: tableW }}
|
style={{ gridTemplateColumns: template, width: tableW }}
|
||||||
className={cn(ROW, 'text-left border-b border-border/20 transition-colors',
|
className={cn(ROW, 'text-left border-b border-border/20 transition-colors',
|
||||||
replying ? 'bg-success/20 hover:bg-success/25'
|
replying ? 'bg-success/25 hover:bg-success/30 border-l-2 border-l-success'
|
||||||
|
// The station being called: a tint saying "he is on the
|
||||||
|
// air", not the QSO treatment above — most of what he
|
||||||
|
// sends is to other people.
|
||||||
: worked ? 'bg-danger/15 hover:bg-danger/20'
|
: worked ? 'bg-danger/15 hover:bg-danger/20'
|
||||||
: mine ? 'bg-info/10'
|
: mine ? 'bg-info/10'
|
||||||
: 'hover:bg-muted/50')}
|
: 'hover:bg-muted/50')}
|
||||||
@@ -1034,6 +1278,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
{hhmmssCompact(d.at)}
|
{hhmmssCompact(d.at)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* Which receiver heard it — present only while more than one
|
||||||
|
is feeding a merged list. */}
|
||||||
|
{cols.some((c) => c.key === 'rx') && (
|
||||||
|
<span className={cn(CELL, 'text-[11px] text-muted-foreground truncate')}
|
||||||
|
title={d.instance ?? ''}>
|
||||||
|
{decoderName(d.instance)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<span className={cn(CELL, 'justify-end font-mono text-[13px] font-semibold tabular-nums', snrTone(d.snr))}>
|
<span className={cn(CELL, 'justify-end font-mono text-[13px] font-semibold tabular-nums', snrTone(d.snr))}>
|
||||||
{d.snr > 0 ? `+${d.snr}` : d.snr}
|
{d.snr > 0 ? `+${d.snr}` : d.snr}
|
||||||
</span>
|
</span>
|
||||||
@@ -1067,7 +1320,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
{/* The message carries the callsign already — which is why
|
{/* The message carries the callsign already — which is why
|
||||||
there is no column repeating it. */}
|
there is no column repeating it. */}
|
||||||
<span className={cn(CELL, 'font-mono text-[13px]')}>
|
<span className={cn(CELL, 'font-mono text-[13px]')}>
|
||||||
<span className="truncate">{renderMsg(d.msg ?? '', me, calling)}</span>
|
<span className="truncate">{renderMsg(d.msg ?? '', me, calling, replying)}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* The decode's own grid first, the remembered one as the
|
{/* The decode's own grid first, the remembered one as the
|
||||||
@@ -1080,6 +1333,23 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
<span className="truncate">{d.grid || e?.grid || ''}</span>
|
<span className="truncate">{d.grid || e?.grid || ''}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<span className={cn(CELL, 'justify-end font-mono text-[11px] tabular-nums text-muted-foreground/80')}>
|
||||||
|
{(() => {
|
||||||
|
const g = d.grid || e?.grid || '';
|
||||||
|
const path = myGrid && g ? pathBetween(myGrid, g) : null;
|
||||||
|
return path ? distanceValue(path.distanceShort).toLocaleString() : '';
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className={cn(CELL, 'gap-1.5')}>
|
||||||
|
{e?.state && (
|
||||||
|
<>
|
||||||
|
<span className="rounded px-1.5 py-px text-[11px] font-bold bg-info/15 text-info border border-info/40 shrink-0">{e.state}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground truncate">{US_STATES[e.state.toUpperCase()] ?? ''}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
<span className={cn(CELL, 'text-[11px] text-muted-foreground')}>
|
<span className={cn(CELL, 'text-[11px] text-muted-foreground')}>
|
||||||
<span className="truncate">{e?.country ?? ''}</span>
|
<span className="truncate">{e?.country ?? ''}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -1096,27 +1366,43 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
{t('dec.wkd')}
|
{t('dec.wkd')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{entity && (
|
{/* Plainest first: the LoTW letter, the worked note, then
|
||||||
<span className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', entity.cls)}>
|
the coloured badges — the watch list leading them, being
|
||||||
{t(entity.label)}
|
the operator's own answer rather than the log's. */}
|
||||||
|
{isWatched(d.call, watchlist) && (
|
||||||
|
<span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0 text-white"
|
||||||
|
style={{ background: '#f472b6' }} title={t('dec.wlTip')}>
|
||||||
|
{t('dec.wl')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{entities.map((b) => (
|
||||||
|
<span key={b.label}
|
||||||
|
title={e?.unconf_status ? t('dec.unconfTip') : undefined}
|
||||||
|
className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0',
|
||||||
|
e?.unconf_status ? 'border bg-transparent' : b.cls)}
|
||||||
|
style={e?.unconf_status ? { color: b.colour, borderColor: b.colour, borderStyle: 'dashed', opacity: 0.6 } : undefined}>
|
||||||
|
{t(b.label)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
{extras.map((b) => {
|
{extras.map((b) => {
|
||||||
// A square WORKED but not yet confirmed is a different job
|
// A square WORKED but not yet confirmed is a different job
|
||||||
// from one never worked: a QSL to chase, not a QSO to make.
|
// from one never worked: a QSL to chase, not a QSO to make.
|
||||||
// Same hue, held back — the badge reads as "less" of the
|
// Same hue, held back — the badge reads as "less" of the
|
||||||
// same thing, and its label says which. One badge for both
|
// same thing, and its label says which. One badge for both
|
||||||
// is what made a station worked an hour ago read as new.
|
// is what made a station worked an hour ago read as new.
|
||||||
const unconf = b.key === 'new_grid' && e?.grid_state === 'unconf';
|
const unconf = (b.key === 'new_grid' && e?.grid_state === 'unconf')
|
||||||
|
|| (b.key === 'new_state' && !!e?.unconf_state)
|
||||||
|
|| (b.key === 'new_county' && !!e?.unconf_cty)
|
||||||
|
|| (b.key === 'new_pfx' && !!e?.unconf_pfx);
|
||||||
const c = markerColour(b.marker);
|
const c = markerColour(b.marker);
|
||||||
return (
|
return (
|
||||||
<span key={b.key as string}
|
<span key={b.key as string}
|
||||||
title={unconf ? t('dec.bgGridUnconfTip') : undefined}
|
title={unconf ? t('dec.unconfTip') : undefined}
|
||||||
className="rounded border px-1 py-px text-[10px] font-semibold uppercase tracking-wide bg-transparent shrink-0"
|
className="rounded border px-1 py-px text-[10px] font-semibold uppercase tracking-wide bg-transparent shrink-0"
|
||||||
style={unconf
|
style={unconf
|
||||||
? { borderColor: c, color: c, opacity: 0.45, borderStyle: 'dashed' }
|
? { borderColor: c, color: c, opacity: 0.45, borderStyle: 'dashed' }
|
||||||
: { borderColor: c, color: c }}>
|
: { borderColor: c, color: c }}>
|
||||||
{unconf ? t('dec.bgGridUnconf') : t(b.label)}
|
{t(b.label)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
||||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
@@ -70,6 +71,11 @@ interface Props {
|
|||||||
band: string;
|
band: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
bands?: string[]; // configured bands for the worked-before matrix columns
|
bands?: string[]; // configured bands for the worked-before matrix columns
|
||||||
|
modes?: string[]; // configured modes, in order — the matrix cycles its digital row through them
|
||||||
|
// The station's satellites, for the SAT_NAME dropdown. Passed in rather than
|
||||||
|
// read here: the list lives in Preferences, and App already reloads it when
|
||||||
|
// Preferences close — a panel reading it once at mount would need a restart.
|
||||||
|
satellites?: string[];
|
||||||
// When the entry form is empty and a QSO is selected in the log grid, the
|
// When the entry form is empty and a QSO is selected in the log grid, the
|
||||||
// Stats (F1) matrix shows THAT contact's entity instead of sitting blank.
|
// Stats (F1) matrix shows THAT contact's entity instead of sitting blank.
|
||||||
// Only the matrix is redirected — every other tab still edits the live entry.
|
// Only the matrix is redirected — every other tab still edits the live entry.
|
||||||
@@ -150,7 +156,7 @@ function Field({ label, span = 1, className, children }: { label: string; span?:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
|
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, modes, satellites = [], slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||||
@@ -289,6 +295,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
currentBand={slotCall ? (slotBand ?? '') : band}
|
currentBand={slotCall ? (slotBand ?? '') : band}
|
||||||
currentMode={slotCall ? (slotMode ?? '') : mode}
|
currentMode={slotCall ? (slotMode ?? '') : mode}
|
||||||
bands={bands}
|
bands={bands}
|
||||||
|
modes={modes}
|
||||||
hasCall={slotCall ? true : callsign.trim() !== ''}
|
hasCall={slotCall ? true : callsign.trim() !== ''}
|
||||||
forCall={slotCall}
|
forCall={slotCall}
|
||||||
onEditQso={onEditQso}
|
onEditQso={onEditQso}
|
||||||
@@ -478,7 +485,25 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
{satelliteMode && (
|
{satelliteMode && (
|
||||||
<>
|
<>
|
||||||
<Field label={t('detp.satName')} span={3}>
|
<Field label={t('detp.satName')} span={3}>
|
||||||
<Input value={details.sat_name} onChange={(e) => onChange({ sat_name: e.target.value })} />
|
{/* The station's own satellites, in alphabetical order, with the
|
||||||
|
box still open to anything typed: SAT_NAME is compared
|
||||||
|
character for character by the awards and by LoTW, so a
|
||||||
|
remembered spelling beats a fresh one every pass — but a bird
|
||||||
|
worked once and never added to the list must not be
|
||||||
|
impossible to log. */}
|
||||||
|
{/* showToggle: without the chevron this control is a text box that
|
||||||
|
happens to open on a keystroke, which nobody discovers — and
|
||||||
|
is exactly what "there is still no dropdown" meant.
|
||||||
|
allowFreeText: the list is the station's own, so a bird
|
||||||
|
worked once and never added to it must still be loggable. */}
|
||||||
|
<Combobox
|
||||||
|
value={details.sat_name}
|
||||||
|
options={satellites}
|
||||||
|
placeholder={t('detp.satName')}
|
||||||
|
showToggle
|
||||||
|
allowFreeText
|
||||||
|
onChange={(v) => onChange({ sat_name: v })}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.satelliteMode')} span={3}>
|
<Field label={t('detp.satelliteMode')} span={3}>
|
||||||
<Input value={details.sat_mode} onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
<Input value={details.sat_mode} onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type Props = {
|
|||||||
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
|
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
|
||||||
};
|
};
|
||||||
|
|
||||||
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F6
|
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F12
|
||||||
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
||||||
// the reserved area. Recording/labeling lives in Settings → Audio.
|
// the reserved area. Recording/labeling lives in Settings → Audio.
|
||||||
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
|
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
|
||||||
|
|||||||
@@ -4,23 +4,24 @@ import {
|
|||||||
GetKenwoodState, RefreshKenwood, SetKenwoodPower, SetKenwoodAFGain, SetKenwoodTX, TuneKenwoodATU,
|
GetKenwoodState, RefreshKenwood, SetKenwoodPower, SetKenwoodAFGain, SetKenwoodTX, TuneKenwoodATU,
|
||||||
SetKenwoodRFGain, SetKenwoodMicGain, SetKenwoodSquelch, SetKenwoodPreamp, SetKenwoodAtt,
|
SetKenwoodRFGain, SetKenwoodMicGain, SetKenwoodSquelch, SetKenwoodPreamp, SetKenwoodAtt,
|
||||||
SetKenwoodNB, SetKenwoodNR, SetKenwoodAGC, SetKenwoodFilter, SetKenwoodAntenna,
|
SetKenwoodNB, SetKenwoodNR, SetKenwoodAGC, SetKenwoodFilter, SetKenwoodAntenna,
|
||||||
SetKenwoodRIT, SetKenwoodXIT, ClearKenwoodRIT, SetKenwoodKeySpeed, ToggleKenwoodATU,
|
SetKenwoodRIT, SetKenwoodXIT, ClearKenwoodRIT, SetKenwoodKeySpeed, ToggleKenwoodATU, SetKenwoodRITOffset, SetKenwoodPanelMode,
|
||||||
GetCATState,
|
GetCATState,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { sMeterRST } from '@/lib/rst';
|
import { sMeterRST } from '@/lib/rst';
|
||||||
|
import { ShiftRow } from '@/components/ShiftRow';
|
||||||
import { MeterBar } from '@/components/MeterBar';
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
import { WheelRange } from '@/components/WheelRange';
|
import { WheelRange } from '@/components/WheelRange';
|
||||||
|
|
||||||
type KenwoodState = {
|
type KenwoodState = {
|
||||||
available: boolean; model?: string; elecraft: boolean; mode?: string;
|
available: boolean; model?: string; elecraft: boolean; mode?: string; data_sub?: string;
|
||||||
transmitting: boolean; split: boolean; split_tx_hz?: number;
|
transmitting: boolean; split: boolean; split_tx_hz?: number;
|
||||||
s_meter: number; s_meter_raw: number;
|
s_meter: number; s_meter_raw: number;
|
||||||
power_meter: number; swr: number; swr_raw: number;
|
power_meter: number; power_w?: number; swr: number; swr_raw: number;
|
||||||
rf_power: number; af_gain: number; rf_gain: number; mic_gain: number; squelch: number;
|
rf_power: number; af_gain: number; rf_gain: number; mic_gain: number; squelch: number;
|
||||||
preamp: boolean; att: boolean; nb: boolean; nr: boolean; agc?: string;
|
preamp: boolean; att: boolean; nb: boolean; nr: boolean; agc?: string;
|
||||||
filter_hz: number; antenna: number; rit: boolean; xit: boolean; key_speed: number;
|
filter_hz: number; antenna: number; rit: boolean; xit: boolean; rit_offset: number; key_speed: number;
|
||||||
meters_provisional: boolean;
|
meters_provisional: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,27 +30,34 @@ const ZERO: KenwoodState = {
|
|||||||
s_meter: 0, s_meter_raw: 0, power_meter: 0, swr: 0, swr_raw: 0,
|
s_meter: 0, s_meter_raw: 0, power_meter: 0, swr: 0, swr_raw: 0,
|
||||||
rf_power: 0, af_gain: 0, rf_gain: 0, mic_gain: 0, squelch: 0,
|
rf_power: 0, af_gain: 0, rf_gain: 0, mic_gain: 0, squelch: 0,
|
||||||
preamp: false, att: false, nb: false, nr: false,
|
preamp: false, att: false, nb: false, nr: false,
|
||||||
filter_hz: 0, antenna: 0, rit: false, xit: false, key_speed: 0,
|
filter_hz: 0, antenna: 0, rit: false, xit: false, rit_offset: 0, key_speed: 0,
|
||||||
meters_provisional: true,
|
meters_provisional: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter widths worth a button. CW work happens at 200-500 Hz, SSB at 2.4-2.8
|
// Filter widths worth a button. CW work happens at 200-500 Hz, SSB at 2.4-2.8
|
||||||
// kHz; the rest of the range is reachable from the radio's own knob, and a
|
// kHz; the rest of the range is reachable from the radio's own knob, and a
|
||||||
// panel offering thirty widths is slower to use than the knob it replaces.
|
// panel offering thirty widths is slower to use than the knob it replaces.
|
||||||
const FILTERS = [200, 400, 700, 1000, 1800, 2400, 2800];
|
// 4.0k is the K3 maximum and the one FT8 wants: a 2.8 kHz filter clips the
|
||||||
|
// top of the FT8 sub-band, and the decodes that go missing are the ones
|
||||||
|
// nobody notices are missing.
|
||||||
|
const FILTERS = [200, 400, 700, 1000, 1800, 2400, 2800, 4000];
|
||||||
|
|
||||||
// Raw S-meter → S units. The K3 answers 0-21 across S0…S9+60; S9 is taken at
|
// Raw S-meter → S units, CALIBRATED against a real K3 beside its own display
|
||||||
// raw 9 and each step above it as 6 dB. PROVISIONAL, like the rest of the
|
// (2026-08): raw 5 reads S7 on the radio, raw 9 reads S9+20. So S9 sits near
|
||||||
// scaling: the raw value is on screen and in the log, so a real radio settles
|
// raw 6.5 — not 9, which showed everything two S-units low — and each raw step
|
||||||
// it rather than this comment.
|
// above it is worth ~8 dB, shown in the 10 dB steps the K3's own meter uses.
|
||||||
const S9_RAW = 9;
|
// A Kenwood answers 0-30 on the same command and keeps the simple 1-per-raw
|
||||||
const DB_PER_RAW = 6;
|
// scale until someone calibrates one against a real radio too.
|
||||||
function sParts(rawV: number): { s: number; over: number; label: string } {
|
const K3_S9_RAW = 6.5;
|
||||||
if (rawV >= S9_RAW) {
|
const K3_DB_PER_RAW = 8;
|
||||||
const over = Math.max(0, Math.round((rawV - S9_RAW) * DB_PER_RAW));
|
function sParts(rawV: number, elecraft: boolean): { s: number; over: number; label: string } {
|
||||||
|
const s9raw = elecraft ? K3_S9_RAW : 9;
|
||||||
|
if (rawV >= s9raw) {
|
||||||
|
let over = Math.max(0, Math.round((rawV - s9raw) * (elecraft ? K3_DB_PER_RAW : 6)));
|
||||||
|
if (elecraft) over = Math.round(over / 10) * 10;
|
||||||
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||||||
}
|
}
|
||||||
const s = Math.max(0, Math.min(9, rawV));
|
const s = Math.max(0, Math.min(9, Math.round(rawV * (elecraft ? 9 / K3_S9_RAW : 1))));
|
||||||
return { s, over: 0, label: `S${s}` };
|
return { s, over: 0, label: `S${s}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,9 +94,34 @@ function Toggle({ label, on, off, onClick }: { label: string; on: boolean; off:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isDataMode: what the rig reports for MD6 varies — "DATA", or the configured
|
||||||
|
// digital default (FT8…) — so the DATA family is "not one of the native modes".
|
||||||
|
function isDataMode(m?: string): boolean {
|
||||||
|
switch ((m ?? '').toUpperCase()) {
|
||||||
|
case '': case 'CW': case 'USB': case 'LSB': case 'SSB': case 'FM': case 'AM': case 'RTTY':
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) => void }) {
|
export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [st, setSt] = useState<KenwoodState>(ZERO);
|
const [st, setSt] = useState<KenwoodState>(ZERO);
|
||||||
|
// Ctrl+Left/Right shifts the RIT by ±10 Hz while RIT is on — the same
|
||||||
|
// keyboard clarifier the Icom and TCI consoles have, for zero-beating a
|
||||||
|
// caller without touching the mouse.
|
||||||
|
const stRef = useRef(st); stRef.current = st;
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
|
||||||
|
const v = stRef.current;
|
||||||
|
if (!v.available || !v.rit) return;
|
||||||
|
e.preventDefault();
|
||||||
|
SetKenwoodRITOffset((v.rit_offset || 0) + (e.key === 'ArrowRight' ? 10 : -10)).catch(() => {});
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
const [freqHz, setFreqHz] = useState(0);
|
const [freqHz, setFreqHz] = useState(0);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
// Optimistic overlay: a slider must follow the finger, not the poll. Dropped
|
// Optimistic overlay: a slider must follow the finger, not the poll. Dropped
|
||||||
@@ -146,6 +179,28 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
|
|||||||
{freqHz > 0 ? (freqHz / 1e6).toFixed(6) : '—'}
|
{freqHz > 0 ? (freqHz / 1e6).toFixed(6) : '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Mode row. The two DATA buttons are why it exists: MD6 alone keeps
|
||||||
|
whatever submode the last session left, and a K3 "in DATA" with FSK D
|
||||||
|
still armed keys FT8 with no audio. DATA sends MD6+DT0 (DATA A, the
|
||||||
|
soundcard path), RTTY sends MD6+DT2 (FSK D). The active DATA button
|
||||||
|
follows the rig's own DT answer. */}
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
{([
|
||||||
|
['CW', 'CW', view.mode === 'CW'],
|
||||||
|
['USB', 'USB', view.mode === 'USB'],
|
||||||
|
['LSB', 'LSB', view.mode === 'LSB'],
|
||||||
|
['DATA', t('k3.modeData'), isDataMode(view.mode) && view.data_sub !== 'FSK D' && view.data_sub !== 'PSK D'],
|
||||||
|
['RTTY', t('k3.modeRtty'), (isDataMode(view.mode) && (view.data_sub === 'FSK D' || view.data_sub === 'PSK D')) || view.mode === 'RTTY'],
|
||||||
|
] as [string, string, boolean][]).map(([cmd, label, on]) => (
|
||||||
|
<button key={cmd} type="button" disabled={off}
|
||||||
|
title={cmd === 'DATA' ? t('k3.modeDataHint') : cmd === 'RTTY' ? t('k3.modeRttyHint') : label}
|
||||||
|
onClick={() => SetKenwoodPanelMode(cmd).catch((e: any) => setErr(String(e?.message ?? e)))}
|
||||||
|
className={cn('px-2.5 py-1.5 text-xs font-bold border-l border-border first:border-l-0',
|
||||||
|
on ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<button type="button" className="text-[11px] text-muted-foreground hover:text-foreground flex items-center gap-1"
|
<button type="button" className="text-[11px] text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||||
onClick={() => RefreshKenwood().catch(() => {})} title={t('k3.refreshHint')}>
|
onClick={() => RefreshKenwood().catch(() => {})} title={t('k3.refreshHint')}>
|
||||||
<SlidersHorizontal className="size-3.5" />
|
<SlidersHorizontal className="size-3.5" />
|
||||||
@@ -160,14 +215,15 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
<MeterBar label="S-METER" value={view.transmitting ? 0 : view.s_meter} lo={0} hi={100}
|
<MeterBar label="S-METER" value={view.transmitting ? 0 : view.s_meter} lo={0} hi={100}
|
||||||
accent="#16a34a" segColor={sSegColor}
|
accent="#16a34a" segColor={sSegColor}
|
||||||
display={view.transmitting ? '—' : sParts(view.s_meter_raw).label}
|
display={view.transmitting ? '—' : sParts(view.s_meter_raw, view.elecraft).label}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (view.transmitting || !onReportRST) return;
|
if (view.transmitting || !onReportRST) return;
|
||||||
const sp = sParts(view.s_meter_raw);
|
const sp = sParts(view.s_meter_raw, view.elecraft);
|
||||||
onReportRST(sMeterRST(sp.s, sp.over, view.mode));
|
onReportRST(sMeterRST(sp.s, sp.over, view.mode));
|
||||||
}}
|
}}
|
||||||
title={t('k3.sMeterHint', { raw: String(view.s_meter_raw) })} />
|
title={t('k3.sMeterHint', { raw: String(view.s_meter_raw) })} />
|
||||||
<MeterBar label="PWR" value={view.transmitting ? view.power_meter : 0} lo={0} hi={100} accent="#0ea5e9" />
|
<MeterBar label="PWR" value={view.transmitting ? view.power_meter : 0} lo={0} hi={100} accent="#0ea5e9"
|
||||||
|
display={view.transmitting && view.elecraft ? `${view.power_w ?? 0} W` : undefined} />
|
||||||
{/* 0 means "not measured", and it must not render as a perfect 1.0:
|
{/* 0 means "not measured", and it must not render as a perfect 1.0:
|
||||||
a match that looks ideal on an antenna nobody has measured is the one
|
a match that looks ideal on an antenna nobody has measured is the one
|
||||||
reading that can cost a radio. */}
|
reading that can cost a radio. */}
|
||||||
@@ -213,7 +269,7 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-6 gap-y-2">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-6 gap-y-2">
|
||||||
<label className="flex items-center gap-2 text-xs">
|
<label className="flex items-center gap-2 text-xs">
|
||||||
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.power')}</span>
|
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.power')}</span>
|
||||||
<WheelRange min={0} max={110} step={5} disabled={off}
|
<WheelRange min={0} max={110} step={1} disabled={off}
|
||||||
value={view.rf_power ?? 0}
|
value={view.rf_power ?? 0}
|
||||||
onChange={(n) => put({ rf_power: n }, () => SetKenwoodPower(n))} />
|
onChange={(n) => put({ rf_power: n }, () => SetKenwoodPower(n))} />
|
||||||
<span className="w-12 text-right font-mono tabular-nums">{view.rf_power ?? 0} W</span>
|
<span className="w-12 text-right font-mono tabular-nums">{view.rf_power ?? 0} W</span>
|
||||||
@@ -276,12 +332,18 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIT / XIT. Clear zeroes both offsets at once — the radio's own RC,
|
{/* RIT / XIT — the shared ShiftRow, exactly as the Icom and TCI consoles
|
||||||
and what an operator means by "clear it". */}
|
drive theirs: ± / wheel / type, Ctrl+←/→ while RIT is on. The K3 has
|
||||||
|
ONE offset for both, so both rows show it, like the Icom's. */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<ShiftRow label="RIT" accent="#8b5cf6" on={view.rit} hz={view.rit_offset || 0} disabled={off}
|
||||||
|
onToggle={() => SetKenwoodRIT(!view.rit).catch(setErrMsg)}
|
||||||
|
onSet={(hz) => SetKenwoodRITOffset(hz).catch(setErrMsg)} />
|
||||||
|
<ShiftRow label="XIT" accent="#f59e0b" on={view.xit} hz={view.rit_offset || 0} disabled={off}
|
||||||
|
onToggle={() => SetKenwoodXIT(!view.xit).catch(setErrMsg)}
|
||||||
|
onSet={(hz) => SetKenwoodRITOffset(hz).catch(setErrMsg)} />
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<Toggle label="RIT" on={view.rit} off={off} onClick={() => SetKenwoodRIT(!view.rit).catch(setErrMsg)} />
|
|
||||||
<Toggle label="XIT" on={view.xit} off={off} onClick={() => SetKenwoodXIT(!view.xit).catch(setErrMsg)} />
|
|
||||||
<Toggle label={t('k3.clear')} on={false} off={off} onClick={() => ClearKenwoodRIT().catch(setErrMsg)} />
|
|
||||||
{/* Antenna only when the radio answered AN — a K3 without the internal
|
{/* Antenna only when the radio answered AN — a K3 without the internal
|
||||||
ATU has one socket and no switch to offer. */}
|
ATU has one socket and no switch to offer. */}
|
||||||
{view.antenna > 0 && (<>
|
{view.antenna > 0 && (<>
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maidenhead';
|
||||||
|
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
// FT Map — the live decode feed as geography: every station decoded in the
|
||||||
|
// last half hour, an arc from the operator's own square to theirs, coloured by
|
||||||
|
// band the way PSK Reporter taught everyone to read it. Wholly display: the
|
||||||
|
// decode list is the same one the FT decodes tab shows, and a station with no
|
||||||
|
// grid (never sent one in a CQ) simply cannot be placed and is not drawn.
|
||||||
|
//
|
||||||
|
// Performance is a design constraint, not an afterthought: the panel only
|
||||||
|
// exists while its tab is active (the parent unmounts it otherwise), the map
|
||||||
|
// renders with canvas (one <canvas>, not one DOM node per arc), the arcs are
|
||||||
|
// capped, and redraws happen when the DECODE LIST changes — every 15 s in FT8,
|
||||||
|
// not per frame.
|
||||||
|
|
||||||
|
export type FTMapDecode = {
|
||||||
|
call: string;
|
||||||
|
grid?: string;
|
||||||
|
band?: string;
|
||||||
|
snr: number;
|
||||||
|
at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The band palette every PSK Reporter user already knows, near enough.
|
||||||
|
const BAND_COLOURS: Record<string, string> = {
|
||||||
|
'160m': '#7f7f7f', '80m': '#e550e5', '60m': '#00008b', '40m': '#5555ff',
|
||||||
|
'30m': '#62d962', '20m': '#f2c40c', '17m': '#f2f261', '15m': '#cca166',
|
||||||
|
'12m': '#b22222', '10m': '#ff69b4', '6m': '#ff0000', '4m': '#cc0044',
|
||||||
|
'2m': '#ff1493', '70cm': '#999900',
|
||||||
|
};
|
||||||
|
const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9ca3af';
|
||||||
|
|
||||||
|
const MAX_ARCS = 300;
|
||||||
|
const MAX_AGE_MS = 30 * 60_000;
|
||||||
|
|
||||||
|
export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
|
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||||
|
const baseRef = useRef<L.TileLayer | null>(null);
|
||||||
|
const labelsRef = useRef<L.TileLayer | null>(null);
|
||||||
|
const [basemap, setBasemap] = useState<BasemapKey>(() =>
|
||||||
|
(localStorage.getItem('opslog.ftmapBase') as BasemapKey) || 'satellite');
|
||||||
|
|
||||||
|
// The map itself, once.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!divRef.current || mapRef.current) return;
|
||||||
|
// ONE world: noWrap tiles inside hard bounds, no side-by-side copies —
|
||||||
|
// and the space beyond the edge is the theme's own surface (style.css).
|
||||||
|
const m = L.map(divRef.current, {
|
||||||
|
zoomControl: true, attributionControl: true,
|
||||||
|
// No maxBounds: with the world smaller than the window the clamp
|
||||||
|
// dragged every zoom into a corner. noWrap tiles alone keep one world.
|
||||||
|
worldCopyJump: false, preferCanvas: true,
|
||||||
|
center: [25, 0], zoom: 2, minZoom: 2,
|
||||||
|
});
|
||||||
|
mapRef.current = m;
|
||||||
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
|
// Leaflet measures its container ONCE, when the map is created, and then
|
||||||
|
// draws tiles for that size for ever. This panel is mounted the moment its
|
||||||
|
// tab is selected — before the flex layout has settled — and the window can
|
||||||
|
// be resized under it, so the stale measurement showed as a strip of dead
|
||||||
|
// space along the bottom where tiles were never asked for. The observer
|
||||||
|
// hands it the real size whenever the box changes.
|
||||||
|
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
|
||||||
|
ro.observe(divRef.current);
|
||||||
|
// Once more after the first paint: the first observation can arrive while
|
||||||
|
// the panel is still zero-height, and no further resize follows a layout
|
||||||
|
// that settles by itself.
|
||||||
|
const settle = window.setTimeout(() => m.invalidateSize({ animate: false }), 100);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(settle);
|
||||||
|
ro.disconnect();
|
||||||
|
m.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
layerRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Basemap follows the picker.
|
||||||
|
useEffect(() => {
|
||||||
|
const m = mapRef.current;
|
||||||
|
if (!m) return;
|
||||||
|
baseRef.current?.remove(); labelsRef.current?.remove();
|
||||||
|
const bm = BASEMAPS[basemap];
|
||||||
|
const opts: L.TileLayerOptions = {
|
||||||
|
maxNativeZoom: bm.maxNativeZoom,
|
||||||
|
noWrap: true,
|
||||||
|
bounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)),
|
||||||
|
};
|
||||||
|
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||||
|
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
||||||
|
localStorage.setItem('opslog.ftmapBase', basemap);
|
||||||
|
}, [basemap]);
|
||||||
|
|
||||||
|
// The arcs, redrawn when the decode list changes. Newest last so they paint
|
||||||
|
// on top; opacity falls with age so the map reads as "now" with a memory.
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = layerRef.current;
|
||||||
|
if (!layer) return;
|
||||||
|
layer.clearLayers();
|
||||||
|
const from = gridToLatLon(myGrid);
|
||||||
|
if (!from) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const placed = decodes
|
||||||
|
.filter((d) => d.grid && Date.parse(d.at) > now - MAX_AGE_MS)
|
||||||
|
.slice(-MAX_ARCS);
|
||||||
|
// One line per CALL (its freshest sighting): the same CQer decoded thirty
|
||||||
|
// times in ten minutes is one path on the air, not thirty strokes of it.
|
||||||
|
const byCall = new Map<string, FTMapDecode>();
|
||||||
|
for (const d of placed) byCall.set(d.call.toUpperCase(), d);
|
||||||
|
L.circleMarker([from.lat, from.lon], {
|
||||||
|
radius: 5, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1,
|
||||||
|
}).addTo(layer);
|
||||||
|
for (const d of byCall.values()) {
|
||||||
|
const to = gridToLatLon(d.grid!);
|
||||||
|
if (!to) continue;
|
||||||
|
const age = now - Date.parse(d.at);
|
||||||
|
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
|
||||||
|
const colour = bandColour(d.band);
|
||||||
|
// Cut at the antimeridian: this map shows ONE world, so a path running
|
||||||
|
// past ±180 has to leave one edge and come back at the other. Without it
|
||||||
|
// every arc out of VK or ZL was drawn into the blank space off the side
|
||||||
|
// of the map, its far end sitting alone on the opposite coast.
|
||||||
|
const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48));
|
||||||
|
L.polyline(pts as L.LatLngExpression[][], {
|
||||||
|
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
||||||
|
}).addTo(layer);
|
||||||
|
L.circleMarker([to.lat, to.lon], {
|
||||||
|
radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
|
||||||
|
}).bindTooltip(`${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`, { direction: 'top' })
|
||||||
|
.addTo(layer);
|
||||||
|
}
|
||||||
|
}, [decodes, myGrid]);
|
||||||
|
|
||||||
|
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
||||||
|
return (
|
||||||
|
// isolate: Leaflet stacks its panes and controls up to z-index 1000, which
|
||||||
|
// beat the app menus and the Settings dialog. A stacking context of our own
|
||||||
|
// keeps all of it inside this panel.
|
||||||
|
<div className="relative isolate z-0 h-full w-full min-h-0">
|
||||||
|
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
|
||||||
|
{/* Basemap picker, MainMap's own vocabulary. */}
|
||||||
|
<div className="absolute top-2 left-12 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
|
||||||
|
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||||
|
<button key={k} type="button" onClick={() => setBasemap(k)}
|
||||||
|
className={cn('px-2 py-0.5 rounded text-[11px]',
|
||||||
|
basemap === k ? 'bg-primary text-primary-foreground font-semibold' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
|
{BASEMAPS[k].label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Band legend — only the bands actually on screen. */}
|
||||||
|
{bands.length > 0 && (
|
||||||
|
<div className="absolute bottom-2 left-2 z-[1000] flex flex-wrap gap-x-2.5 gap-y-1 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border">
|
||||||
|
{bands.map((b) => (
|
||||||
|
<span key={b} className="flex items-center gap-1 text-[11px] text-foreground">
|
||||||
|
<span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />
|
||||||
|
{b.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!gridToLatLon(myGrid) && (
|
||||||
|
<div className="absolute inset-0 z-[1000] flex items-center justify-center pointer-events-none">
|
||||||
|
<span className="rounded-md bg-background/90 border border-border px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
{t('ftmap.noGrid')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -86,6 +86,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
|
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
|
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
|
||||||
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
||||||
|
{ value: 'clublog_qso_download_status', label: 'fltb.fClublogRcvd', type: 'text' },
|
||||||
|
{ value: 'clublog_qso_download_date', label: 'fltb.fClublogRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
||||||
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
||||||
// HAMLOG.online: no promoted column, filtered through extras_json (see
|
// HAMLOG.online: no promoted column, filtered through extras_json (see
|
||||||
@@ -94,6 +96,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' },
|
{ value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' },
|
||||||
{ value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' },
|
{ value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' },
|
||||||
{ value: 'hamlog_rcvd_date', label: 'fltb.fHamlogRcvdDate', type: 'adifdate' },
|
{ value: 'hamlog_rcvd_date', label: 'fltb.fHamlogRcvdDate', type: 'adifdate' },
|
||||||
|
{ value: 'hamqth_sent', label: 'fltb.fHamqthSent', type: 'text' },
|
||||||
|
{ value: 'hamqth_sent_date', label: 'fltb.fHamqthSentDate', type: 'adifdate' },
|
||||||
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
||||||
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||||
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||||
|
|||||||
@@ -725,7 +725,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
const next = { ...rstChase, offset_hz: v };
|
const next = { ...rstChase, offset_hz: v };
|
||||||
setRstChase(next); SaveFlexRSTChase(next as any).catch(() => {});
|
setRstChase(next); SaveFlexRSTChase(next as any).catch(() => {});
|
||||||
}}
|
}}
|
||||||
className="w-12 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono" />
|
className="w-16 h-6 rounded border border-input bg-background px-1.5 text-[11px] font-mono" />
|
||||||
Hz
|
Hz
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -128,15 +128,11 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
|||||||
// impossible on any window wider than it is tall: one step out was already
|
// impossible on any window wider than it is tall: one step out was already
|
||||||
// too far in.
|
// too far in.
|
||||||
minZoom: 0,
|
minZoom: 0,
|
||||||
maxBoundsViscosity: 1, // don't let a drag slide the world off to one side
|
|
||||||
}).setView([20, 0], 2);
|
}).setView([20, 0], 2);
|
||||||
// The whole world, once, whatever the window is shaped like — computed by
|
// The whole world, once, whatever the window is shaped like — computed by
|
||||||
// Leaflet from the container rather than guessed with a zoom number.
|
// Leaflet from the container rather than guessed with a zoom number.
|
||||||
m.fitWorld({ animate: false });
|
m.fitWorld({ animate: false });
|
||||||
// Latitude only: the poles are the edge of the projection and there is
|
|
||||||
// nothing beyond them, while leaving longitude free keeps a drag from
|
|
||||||
// fighting the operator near the date line.
|
|
||||||
m.setMaxBounds(L.latLngBounds(L.latLng(-85, -Infinity), L.latLng(85, Infinity)));
|
|
||||||
mapRef.current = m;
|
mapRef.current = m;
|
||||||
layerRef.current = L.layerGroup().addTo(m);
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
// Leaflet measures its container ONCE, when the map is created, and never
|
// Leaflet measures its container ONCE, when the map is created, and never
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna, Filter, Power } from 'lucide-react';
|
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna, Filter, Power, Volume2, VolumeX } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetIcomState, IcomRefresh,
|
GetIcomState, IcomRefresh,
|
||||||
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
||||||
IcomSetANF, IcomSetAPF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
IcomSetANF, IcomSetAPF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
||||||
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
|
AudioMonitorActive, AudioStartMonitor, AudioStopMonitor,
|
||||||
IcomSetScope, IcomScopeData, IcomSetScopeMode, IcomSetScopeEdges, GetCATState, SetCATFrequency, SetCATMode,
|
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetATU, IcomConsolePTT,
|
||||||
|
GetCATState, SetCATFrequency, SetCATMode,
|
||||||
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
|
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
|
||||||
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
|
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
|
||||||
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
|
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
|
||||||
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower,
|
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower, IcomRecallBand,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { sMeterRST } from '@/lib/rst';
|
import { sMeterRST } from '@/lib/rst';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
|
import { ShiftRow } from '@/components/ShiftRow';
|
||||||
|
|
||||||
type IcomState = {
|
type IcomState = {
|
||||||
available: boolean; model?: string; mode?: string;
|
available: boolean; model?: string; mode?: string;
|
||||||
transmitting: boolean; split: boolean;
|
transmitting: boolean; split: boolean; sub_hz?: number; atu_on?: boolean;
|
||||||
s_meter: number; power_meter: number; swr_meter: number;
|
s_meter: number; power_meter: number; swr_meter: number;
|
||||||
rf_power: number; mic_gain: number;
|
rf_power: number; mic_gain: number;
|
||||||
af_gain: number; rf_gain: number;
|
af_gain: number; rf_gain: number;
|
||||||
@@ -50,7 +53,13 @@ const ZERO: IcomState = {
|
|||||||
type Band = { l: string; hz: number };
|
type Band = { l: string; hz: number };
|
||||||
|
|
||||||
const HF_BANDS: Band[] = [
|
const HF_BANDS: Band[] = [
|
||||||
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 },
|
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 },
|
||||||
|
// 60 m: the middle of the IARU Region 1 allocation (5351.5-5366.5 kHz), which
|
||||||
|
// every 60 m-capable rig can display. Where the band is channelised (the US)
|
||||||
|
// the operator moves to their channel from here — the button is a way onto
|
||||||
|
// the band, not a claim about what may be transmitted on it.
|
||||||
|
{ l: '60', hz: 5_354_000 },
|
||||||
|
{ l: '40', hz: 7_100_000 },
|
||||||
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
||||||
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
||||||
];
|
];
|
||||||
@@ -59,8 +68,9 @@ const B2 = { l: '2', hz: 144_300_000 }; // SSB calling
|
|||||||
const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling
|
const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling
|
||||||
const B23 = { l: '23cm', hz: 1_296_200_000 }; // SSB calling
|
const B23 = { l: '23cm', hz: 1_296_200_000 }; // SSB calling
|
||||||
|
|
||||||
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
// These frequencies are the FALLBACK: with the band stacking registers switched
|
||||||
// the plain SetFrequency command — no band-stacking codes needed.
|
// on the radio is asked where the operator last was instead, and one of these is
|
||||||
|
// only sent for a band or a model whose register cannot be read.
|
||||||
//
|
//
|
||||||
// Which buttons to OFFER depends on the radio, exactly as the attenuator steps
|
// Which buttons to OFFER depends on the radio, exactly as the attenuator steps
|
||||||
// do below. An IC-9700 has no HF at all, yet the console was showing it 160
|
// do below. An IC-9700 has no HF at all, yet the console was showing it 160
|
||||||
@@ -75,19 +85,47 @@ function bandsFor(model?: string): Band[] {
|
|||||||
return [...HF_BANDS, B6];
|
return [...HF_BANDS, B6];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for
|
// Mode buttons for the console (like RS-BA1's row).
|
||||||
// SSB by frequency and the rig's data variant for digital modes.
|
//
|
||||||
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM'];
|
// LSB and USB by NAME, not one "SSB" button that resolves by band: the band
|
||||||
|
// convention is right for a logged mode and useless when the operator means
|
||||||
|
// "put this radio in USB on 40 m", which the console could not express at all.
|
||||||
|
//
|
||||||
|
// PSK is native only on the 7610/7760/7851 class; every other rig NAKs 0x12, so
|
||||||
|
// there the button is dead furniture — see modesFor. Soundcard PSK31 rides on
|
||||||
|
// DATA, which every rig can do.
|
||||||
|
const MODES_BASE = ['LSB', 'USB', 'CW', 'RTTY', 'AM', 'FM', 'DATA'];
|
||||||
|
|
||||||
|
function hasNativePSK(model?: string): boolean {
|
||||||
|
const m = (model ?? '').toUpperCase();
|
||||||
|
return m.includes('7610') || m.includes('7760') || m.includes('7851') ||
|
||||||
|
m.includes('7800') || m.includes('7700');
|
||||||
|
}
|
||||||
|
|
||||||
|
function modesFor(model?: string): string[] {
|
||||||
|
if (!hasNativePSK(model)) return MODES_BASE;
|
||||||
|
return [...MODES_BASE.slice(0, 4), 'PSK', ...MODES_BASE.slice(4)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which radios actually have an antenna selector on the CI-V command (0x12).
|
||||||
|
// An IC-7300 has ONE socket: offering it ANT1/ANT2 was two buttons that could
|
||||||
|
// only ever disagree with the front panel.
|
||||||
|
function hasAntennaSelector(model?: string): boolean {
|
||||||
|
const m = (model ?? '').toUpperCase();
|
||||||
|
return m.includes('7610') || m.includes('7760') || m.includes('7851') ||
|
||||||
|
m.includes('7800') || m.includes('7700') || m.includes('9700');
|
||||||
|
}
|
||||||
|
|
||||||
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
|
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
|
||||||
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
|
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
|
||||||
// dB stepped attenuator; the IC-7300/705/7100 have a single 20 dB attenuator; the
|
// dB stepped attenuator, and the IC-7760 the same (confirmed on a real one); the
|
||||||
|
// IC-7300/705/7100 have a single 20 dB attenuator; the
|
||||||
// IC-9700 a single 10 dB. Offering the wrong steps = a dead button (the rig NAKs
|
// IC-9700 a single 10 dB. Offering the wrong steps = a dead button (the rig NAKs
|
||||||
// e.g. 6 dB on a 7300). Default to the common single 20 dB for unknown models.
|
// e.g. 6 dB on a 7300). Default to the common single 20 dB for unknown models.
|
||||||
function attOptions(model?: string): { v: string; l: string }[] {
|
function attOptions(model?: string): { v: string; l: string }[] {
|
||||||
const m = (model ?? '').toUpperCase();
|
const m = (model ?? '').toUpperCase();
|
||||||
const OFF = { v: '0', l: 'OFF' };
|
const OFF = { v: '0', l: 'OFF' };
|
||||||
if (/(7610|7700|7800|7850|7851)/.test(m)) {
|
if (/(7610|7700|7760|7800|7850|7851)/.test(m)) {
|
||||||
return [OFF, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
return [OFF, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
||||||
}
|
}
|
||||||
if (m.includes('9700')) return [OFF, { v: '10', l: '10dB' }];
|
if (m.includes('9700')) return [OFF, { v: '10', l: '10dB' }];
|
||||||
@@ -126,9 +164,50 @@ function fmtVFO(hz?: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// modeMatches marks a mode button active, folding the rig's USB/LSB into SSB.
|
// modeMatches marks a mode button active, folding the rig's USB/LSB into SSB.
|
||||||
function modeMatches(btn: string, cur?: string): boolean {
|
// icomWatts turns the backend's 0-100 meter percentage back into watts on the
|
||||||
|
// IC-7760's own meter face. The backend value is linear in the RAW meter byte
|
||||||
|
// (0-255 → 0-100), but Icom's calibration is not: raw 143 is half deflection
|
||||||
|
// and raw 213 is full scale. On a real 7760 a measured 100 W sits at half
|
||||||
|
// deflection of the 250 W face — the linear ×2.5 first tried showed 140 W for
|
||||||
|
// it. Below half scale watts run 0→100, above it 100→250.
|
||||||
|
// The anchors are MEASURED on the real radio, not derived: a known 50 W read
|
||||||
|
// raw ≈89 and a known 100 W read raw 143 (Icom's documented half-deflection),
|
||||||
|
// with raw 213 = full scale = 250 W. The face is not linear in watts at the
|
||||||
|
// bottom — a two-segment guess showed 50 W as 62 — so watts interpolate
|
||||||
|
// between the measured anchors, and a new measurement just adds a row.
|
||||||
|
// Third measured anchor (2026-08-30): a real 200 W read full deflection —
|
||||||
|
// raw 213 is 200 W on this rig, not the 250 the printed face suggests.
|
||||||
|
const ICOM_7760_PO: [number, number][] = [[0, 0], [89, 50], [143, 100], [213, 200]];
|
||||||
|
function icomWatts(pct: number): { w: number; defl: number } {
|
||||||
|
const raw = Math.max(0, pct * 2.55);
|
||||||
|
const defl = raw <= 143 ? (raw / 143) * 50 : Math.min(100, 50 + ((raw - 143) / 70) * 50);
|
||||||
|
let w = 200;
|
||||||
|
for (let i = 1; i < ICOM_7760_PO.length; i++) {
|
||||||
|
const [r0, w0] = ICOM_7760_PO[i - 1], [r1, w1] = ICOM_7760_PO[i];
|
||||||
|
if (raw <= r1) { w = w0 + ((raw - r0) / (r1 - r0)) * (w1 - w0); break; }
|
||||||
|
}
|
||||||
|
return { w: Math.round(w), defl };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which sideband a bare "SSB" means at this frequency — the same convention the
|
||||||
|
// backend applies when it resolves the mode for the radio.
|
||||||
|
function sideForHz(hz?: number): string | null {
|
||||||
|
if (!hz || hz <= 0) return null;
|
||||||
|
return hz < 10_000_000 ? 'LSB' : 'USB';
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeMatches(btn: string, cur?: string, hz?: number): boolean {
|
||||||
if (!cur) return false;
|
if (!cur) return false;
|
||||||
if (btn === 'SSB') return cur === 'SSB' || cur === 'USB' || cur === 'LSB';
|
// A rig that reports the folded ADIF "SSB" still lights the side its
|
||||||
|
// frequency implies, so the row is never blank on a phone contact.
|
||||||
|
if (btn === 'USB' || btn === 'LSB') {
|
||||||
|
if (cur === btn) return true;
|
||||||
|
return cur === 'SSB' && btn === (sideForHz(hz) ?? '');
|
||||||
|
}
|
||||||
|
// The backend surfaces USB-D as the operator's digital default (FT8…), or as
|
||||||
|
// plain DATA — either way it is the DATA button that should light.
|
||||||
|
if (btn === 'DATA') return ['DATA', 'FT8', 'FT4', 'JS8', 'JT65', 'JT9', 'MFSK', 'OLIVIA'].includes(cur);
|
||||||
|
if (btn === 'PSK') return cur === 'PSK' || cur === 'PSK31';
|
||||||
return btn === cur;
|
return btn === cur;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,51 +318,25 @@ function Meter({ label, value, accent, scale, onClick, title }: { label: string;
|
|||||||
<div className="flex-1 h-2.5 rounded-full bg-muted/60 overflow-hidden">
|
<div className="flex-1 h-2.5 rounded-full bg-muted/60 overflow-hidden">
|
||||||
<div className="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
|
<div className="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="w-10 text-right text-[11px] font-mono tabular-nums text-muted-foreground">{scale ?? v}</span>
|
<span className="w-14 shrink-0 whitespace-nowrap text-right text-[11px] font-mono tabular-nums text-muted-foreground">{scale ?? v}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
return <button type="button" onClick={onClick} title={title} className="flex items-center gap-2 w-full rounded hover:bg-muted/50 -mx-1 px-1 py-0.5">{body}</button>;
|
return <button type="button" onClick={onClick} title={title} className="flex items-center gap-2 w-full rounded cursor-pointer hover:bg-muted/50 -mx-1 px-1 py-0.5">{body}</button>;
|
||||||
}
|
}
|
||||||
return <div className="flex items-center gap-2">{body}</div>;
|
return <div className="flex items-center gap-2">{body}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShiftRow — a RIT / ΔTX offset control: on/off chip + a wheel-adjustable signed
|
|
||||||
// offset (±10 Hz per notch or per ± button) + a clear (0) button.
|
|
||||||
function ShiftRow({ label, on, hz, accent, onToggle, onDelta, onClear }: {
|
|
||||||
label: string; on: boolean; hz: number; accent: string;
|
|
||||||
onToggle: () => void; onDelta: (d: number) => void; onClear: () => void;
|
|
||||||
}) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const cb = useRef(onDelta); cb.current = onDelta;
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
const onWheel = (e: WheelEvent) => { e.preventDefault(); cb.current(e.deltaY < 0 ? 10 : -10); };
|
|
||||||
el.addEventListener('wheel', onWheel, { passive: false });
|
|
||||||
return () => el.removeEventListener('wheel', onWheel);
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Chip on={on} onClick={onToggle} label={label} />
|
|
||||||
<div ref={ref} title="Wheel / ± to shift"
|
|
||||||
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none cursor-ns-resize',
|
|
||||||
on ? 'border-border bg-muted/40' : 'border-border/60 bg-muted/20 opacity-60')}>
|
|
||||||
<button type="button" onClick={() => onDelta(-10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">−</button>
|
|
||||||
<span className="text-sm font-mono font-bold tabular-nums" style={{ color: on ? accent : undefined }}>
|
|
||||||
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz)} Hz
|
|
||||||
</span>
|
|
||||||
<button type="button" onClick={() => onDelta(10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">+</button>
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={onClear}
|
|
||||||
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">0</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the
|
// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the
|
||||||
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
|
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
|
||||||
// the RST-tx value on click.
|
// the RST-tx value on click.
|
||||||
|
// Green to S9, amber through +20, red above — the Elecraft console's scale.
|
||||||
|
function sSegColor(frac: number) {
|
||||||
|
if (frac > 0.78) return '#dc2626';
|
||||||
|
if (frac > 0.55) return '#f59e0b';
|
||||||
|
return '#16a34a';
|
||||||
|
}
|
||||||
|
|
||||||
function sParts(v: number): { s: number; over: number; label: string } {
|
function sParts(v: number): { s: number; over: number; label: string } {
|
||||||
if (v >= 47) {
|
if (v >= 47) {
|
||||||
const over = Math.max(0, Math.round((v - 47) * 60 / 47));
|
const over = Math.max(0, Math.round((v - 47) * 60 / 47));
|
||||||
@@ -311,291 +364,48 @@ function wfColor(v: number): [number, number, number] {
|
|||||||
return WF_STOPS[WF_STOPS.length - 1][1];
|
return WF_STOPS[WF_STOPS.length - 1][1];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScopePanadapter — enables the rig's spectrum-scope stream and draws the
|
// The spectrum scope is GONE, deliberately. Every Icom streams its waveform
|
||||||
// reassembled sweep as a modern SDR panadapter: a glowing filled spectrum trace
|
// differently — the IC-7851 controls a scope it never streams, and a real
|
||||||
// on top and a scrolling colour waterfall below. Amplitudes are raw rig scale
|
// IC-7760 stops answering CI-V altogether a few frames in, taking CAT and
|
||||||
// (~0-160), normalised to the tallest recent peak so the trace fills the height.
|
// audio down with it — and chasing a per-model frame layout for a decoration
|
||||||
function ScopePanadapter() {
|
// is not worth a console that drops the link. The radio has a better scope
|
||||||
const { t } = useI18n();
|
// on its own front panel.
|
||||||
const [on, setOn] = useState(false);
|
|
||||||
const [fixed, setFixed] = useState(true);
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const wfRef = useRef<HTMLCanvasElement>(null); // waterfall
|
|
||||||
const peakRef = useRef(160); // running amplitude ceiling for auto-scale
|
|
||||||
const holdRef = useRef<number[]>([]); // per-bin peak-hold line
|
|
||||||
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
|
|
||||||
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
|
|
||||||
const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶)
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
const next = !on;
|
|
||||||
setOn(next);
|
|
||||||
IcomSetScope(next).catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Centre/pan the FIXED scope: set the edges to centre ±50 kHz (a 100 kHz
|
|
||||||
// window). "Centre" uses the live VFO; ◀/▶ shift the window by 50 kHz. This
|
|
||||||
// just writes the rig's fixed edges — simple and independent of the waveform
|
|
||||||
// decode.
|
|
||||||
const SCOPE_HALF = 50_000;
|
|
||||||
const applyEdges = (center: number) => {
|
|
||||||
if (center <= 0) return;
|
|
||||||
centerRef.current = center;
|
|
||||||
setFixed(true);
|
|
||||||
IcomSetScopeEdges(center - SCOPE_HALF, center + SCOPE_HALF).catch(() => {});
|
|
||||||
};
|
|
||||||
const centerOnVfo = async () => {
|
|
||||||
let c = vfoRef.current;
|
|
||||||
if (c <= 0) { try { const cs = await GetCATState(); c = cs?.freq_hz || 0; } catch {} }
|
|
||||||
applyEdges(c);
|
|
||||||
};
|
|
||||||
const pan = (dir: number) => applyEdges((centerRef.current || vfoRef.current) + dir * SCOPE_HALF);
|
|
||||||
const setMode = (nextFixed: boolean) => {
|
|
||||||
setFixed(nextFixed);
|
|
||||||
IcomSetScopeMode(nextFixed).catch(() => {});
|
|
||||||
};
|
|
||||||
// Stop the stream when the panel unmounts.
|
|
||||||
useEffect(() => () => { IcomSetScope(false).catch(() => {}); }, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!on) return;
|
|
||||||
let raf = 0, lastSeq = -1, alive = true;
|
|
||||||
const tick = async () => {
|
|
||||||
if (!alive) return;
|
|
||||||
try {
|
|
||||||
const sw = await IcomScopeData();
|
|
||||||
if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) {
|
|
||||||
lastSeq = sw.seq;
|
|
||||||
setFixed(sw.fixed);
|
|
||||||
spanRef.current = { low: sw.low_hz, high: sw.high_hz };
|
|
||||||
let vfo = 0;
|
|
||||||
try {
|
|
||||||
const cs = await GetCATState();
|
|
||||||
vfo = cs?.split && cs.freq_rx_hz ? cs.freq_rx_hz : (cs?.freq_hz || 0);
|
|
||||||
} catch {}
|
|
||||||
if (vfo > 0) vfoRef.current = vfo;
|
|
||||||
draw(sw.amp, sw.low_hz, sw.high_hz, vfoRef.current, sw.fixed);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number;
|
|
||||||
};
|
|
||||||
raf = requestAnimationFrame(tick);
|
|
||||||
return () => { alive = false; cancelAnimationFrame(raf); window.clearTimeout(raf); };
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [on]);
|
|
||||||
|
|
||||||
// Double-click tunes the rig to the clicked frequency.
|
|
||||||
const onDblClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
||||||
const cv = canvasRef.current;
|
|
||||||
const { low, high } = spanRef.current;
|
|
||||||
if (!cv || !(low > 0 && high > low)) return;
|
|
||||||
const rect = cv.getBoundingClientRect();
|
|
||||||
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
||||||
const hz = Math.round((low + frac * (high - low)) / 100) * 100; // nearest 100 Hz
|
|
||||||
SetCATFrequency(hz).catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mouse-wheel over the scope QSYs ±100 Hz. Non-passive listener so we can
|
|
||||||
// preventDefault (else the page scrolls); optimistic vfoRef so quick spins
|
|
||||||
// accumulate before the poll reconciles.
|
|
||||||
useEffect(() => {
|
|
||||||
const el = canvasRef.current;
|
|
||||||
if (!el || !on) return;
|
|
||||||
const onWheel = (e: WheelEvent) => {
|
|
||||||
if (!vfoRef.current) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const next = vfoRef.current + (e.deltaY < 0 ? 100 : -100);
|
|
||||||
vfoRef.current = next;
|
|
||||||
SetCATFrequency(next).catch(() => {});
|
|
||||||
};
|
|
||||||
el.addEventListener('wheel', onWheel, { passive: false });
|
|
||||||
return () => el.removeEventListener('wheel', onWheel);
|
|
||||||
}, [on]);
|
|
||||||
|
|
||||||
const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number, fixedMode: boolean) => {
|
|
||||||
const cv = canvasRef.current;
|
|
||||||
if (!cv) return;
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
const w = cv.clientWidth, h = cv.clientHeight;
|
|
||||||
if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr; }
|
|
||||||
const ctx = cv.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
||||||
|
|
||||||
// Auto-scale: track the peak, decaying slowly so the floor doesn't jump.
|
|
||||||
const peak = Math.max(...amp);
|
|
||||||
peakRef.current = Math.max(peak, peakRef.current * 0.95, 40);
|
|
||||||
const scale = peakRef.current;
|
|
||||||
const n = amp.length;
|
|
||||||
|
|
||||||
// Background — deep navy vertical gradient.
|
|
||||||
const bg = ctx.createLinearGradient(0, 0, 0, h);
|
|
||||||
bg.addColorStop(0, '#0b1220'); bg.addColorStop(1, '#05070e');
|
|
||||||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
|
||||||
|
|
||||||
// Grid.
|
|
||||||
ctx.strokeStyle = 'rgba(120,150,200,0.08)';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
for (let i = 1; i < 4; i++) { const y = (h * i) / 4; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
|
||||||
for (let i = 1; i < 8; i++) { const x = (w * i) / 8; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
|
|
||||||
|
|
||||||
const xOf = (i: number) => (i / (n - 1)) * w;
|
|
||||||
const yOf = (v: number) => h - Math.min(1, v / scale) * h;
|
|
||||||
|
|
||||||
// Peak-hold line (slow decay) — a faint ghost of recent maxima.
|
|
||||||
const hold = holdRef.current;
|
|
||||||
if (hold.length !== n) hold.length = n, hold.fill(0);
|
|
||||||
for (let i = 0; i < n; i++) hold[i] = Math.max(amp[i], hold[i] * 0.92);
|
|
||||||
|
|
||||||
// Filled spectrum area.
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, h);
|
|
||||||
for (let i = 0; i < n; i++) ctx.lineTo(xOf(i), yOf(amp[i]));
|
|
||||||
ctx.lineTo(w, h); ctx.closePath();
|
|
||||||
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
|
||||||
grad.addColorStop(0, 'rgba(56,189,248,0.40)');
|
|
||||||
grad.addColorStop(1, 'rgba(56,189,248,0.02)');
|
|
||||||
ctx.fillStyle = grad; ctx.fill();
|
|
||||||
|
|
||||||
// Peak-hold trace (thin, faint).
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(hold[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
|
|
||||||
ctx.strokeStyle = 'rgba(148,197,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
|
|
||||||
|
|
||||||
// Live spectrum trace with a soft glow.
|
|
||||||
ctx.save();
|
|
||||||
ctx.shadowColor = 'rgba(56,189,248,0.7)'; ctx.shadowBlur = 6;
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(amp[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
|
|
||||||
ctx.strokeStyle = '#7dd3fc'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// VFO marker: you should ALWAYS see where you are. Exact position when the VFO
|
|
||||||
// is inside the span; the centre in CTR mode; clamped to the nearest edge with
|
|
||||||
// a sideways arrow in FIX mode when the fixed scope doesn't cover the VFO (so
|
|
||||||
// you can tell which way to tune to get it back on-screen).
|
|
||||||
const haveVfo = vfoHz > 0 && lowHz > 0 && highHz > lowHz;
|
|
||||||
const inSpan = haveVfo && vfoHz >= lowHz && vfoHz <= highHz;
|
|
||||||
let markerX = -1;
|
|
||||||
let offEdge = 0; // -1 = VFO off the left edge, +1 = off the right
|
|
||||||
if (inSpan) markerX = ((vfoHz - lowHz) / (highHz - lowHz)) * w;
|
|
||||||
else if (!fixedMode) markerX = w / 2;
|
|
||||||
else if (haveVfo) { offEdge = vfoHz < lowHz ? -1 : 1; markerX = offEdge < 0 ? 1 : w - 1; }
|
|
||||||
if (markerX >= 0) {
|
|
||||||
const x = markerX;
|
|
||||||
ctx.fillStyle = 'rgba(244,63,94,0.10)'; ctx.fillRect(x - 5, 0, 10, h);
|
|
||||||
ctx.strokeStyle = 'rgba(244,63,94,0.9)'; ctx.lineWidth = 1.25;
|
|
||||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
|
|
||||||
ctx.fillStyle = 'rgba(244,63,94,0.95)';
|
|
||||||
if (offEdge === 0) {
|
|
||||||
ctx.beginPath(); ctx.moveTo(x - 4, 0); ctx.lineTo(x + 4, 0); ctx.lineTo(x, 6); ctx.closePath(); ctx.fill();
|
|
||||||
} else {
|
|
||||||
const yh = 8; ctx.beginPath(); ctx.moveTo(x, yh - 5); ctx.lineTo(x + offEdge * 7, yh); ctx.lineTo(x, yh + 5); ctx.closePath(); ctx.fill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Frequency scale. In fixed mode the rig reports usable edge frequencies, so
|
|
||||||
// we label low/centre/high from them. In centre mode the header frame's edge
|
|
||||||
// pair isn't a usable low..high range, but the scope is centred on the VFO —
|
|
||||||
// so we always label the centre with the live VFO frequency (which we fetch
|
|
||||||
// each sweep), and only add edge labels when the reported edges genuinely
|
|
||||||
// bracket the VFO. That guarantees you always see your frequency in CTR.
|
|
||||||
const mhz = (hz: number) => (hz / 1e6).toFixed(3);
|
|
||||||
ctx.font = '10px ui-monospace, monospace';
|
|
||||||
ctx.textBaseline = 'bottom';
|
|
||||||
ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3;
|
|
||||||
ctx.fillStyle = 'rgba(226,232,240,0.85)';
|
|
||||||
const label = (txt: string, x: number, align: CanvasTextAlign) => { ctx.textAlign = align; ctx.fillText(txt, x, h - 3); };
|
|
||||||
const validEdges = lowHz > 0 && highHz > lowHz;
|
|
||||||
if (fixedMode) {
|
|
||||||
if (validEdges) {
|
|
||||||
label(mhz(lowHz), 4, 'left');
|
|
||||||
label(mhz((lowHz + highHz) / 2), w / 2, 'center');
|
|
||||||
label(mhz(highHz), w - 4, 'right');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (validEdges && vfoHz >= lowHz && vfoHz <= highHz) {
|
|
||||||
label(mhz(lowHz), 4, 'left');
|
|
||||||
label(mhz(highHz), w - 4, 'right');
|
|
||||||
}
|
|
||||||
if (vfoHz > 0) label(mhz(vfoHz), w / 2, 'center');
|
|
||||||
}
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
|
|
||||||
drawWaterfall(amp, scale);
|
|
||||||
};
|
|
||||||
|
|
||||||
// drawWaterfall scrolls the history down one row and paints the newest sweep
|
|
||||||
// as a colour-mapped line at the top.
|
|
||||||
const drawWaterfall = (amp: number[], scale: number) => {
|
|
||||||
const cv = wfRef.current;
|
|
||||||
if (!cv) return;
|
|
||||||
const w = Math.max(1, cv.clientWidth), h = Math.max(1, cv.clientHeight);
|
|
||||||
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
|
|
||||||
const ctx = cv.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
// Scroll everything down by one pixel row.
|
|
||||||
ctx.drawImage(cv, 0, 0, w, h - 1, 0, 1, w, h - 1);
|
|
||||||
// Paint the new top row.
|
|
||||||
const row = ctx.createImageData(w, 1);
|
|
||||||
const n = amp.length;
|
|
||||||
for (let x = 0; x < w; x++) {
|
|
||||||
const i = Math.min(n - 1, Math.round((x / (w - 1)) * (n - 1)));
|
|
||||||
const [r, g, b] = wfColor(amp[i] / scale);
|
|
||||||
const o = x * 4;
|
|
||||||
row.data[o] = r; row.data[o + 1] = g; row.data[o + 2] = b; row.data[o + 3] = 255;
|
|
||||||
}
|
|
||||||
ctx.putImageData(row, 0, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Collapsible card: when the scope is off, only the header band shows (the
|
|
||||||
// canvas is hidden entirely) so it doesn't waste vertical space. The CTR/FIX
|
|
||||||
// and ON/OFF controls live in the header itself.
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
|
||||||
<Activity className="size-4" style={{ color: '#38bdf8' }} />
|
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('icmp.spectrum')}</span>
|
|
||||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
|
||||||
{on && (
|
|
||||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
|
||||||
<button type="button" onClick={() => pan(-1)} title={t('icmp.scopePanDown')}
|
|
||||||
className="px-2 py-1 text-xs font-bold bg-card text-muted-foreground hover:bg-muted border-r border-border">◀</button>
|
|
||||||
<button type="button" onClick={centerOnVfo} title={t('icmp.scopeCenterVfo')}
|
|
||||||
className="px-2 py-1 text-[11px] font-bold bg-card text-muted-foreground hover:bg-muted border-r border-border">⊙</button>
|
|
||||||
<button type="button" onClick={() => pan(1)} title={t('icmp.scopePanUp')}
|
|
||||||
className="px-2 py-1 text-xs font-bold bg-card text-muted-foreground hover:bg-muted">▶</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{on && (
|
|
||||||
<Segmented value={fixed ? 'FIX' : 'CTR'} options={[{ v: 'CTR', l: 'CTR' }, { v: 'FIX', l: 'FIX' }]}
|
|
||||||
onChange={(v) => setMode(v === 'FIX')} />
|
|
||||||
)}
|
|
||||||
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{on && (
|
|
||||||
<div className="p-3">
|
|
||||||
<div className="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]">
|
|
||||||
<canvas ref={canvasRef} onDoubleClick={onDblClick}
|
|
||||||
className="w-full block cursor-crosshair" style={{ height: 140 }} />
|
|
||||||
<canvas ref={wfRef} className="w-full block" style={{ height: 96 }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// IcomPanel — full control surface (RX DSP + TX) for an Icom on the CI-V backend.
|
|
||||||
// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are
|
|
||||||
// read every cache cycle; DSP set-controls are optimistic and reconcile on the
|
|
||||||
// next poll. Front-panel knob changes for DSP show after ↻ Refresh.
|
|
||||||
export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) {
|
export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) {
|
||||||
|
// The speaker toggle lives HERE, next to ON/OFF, because that is where the
|
||||||
|
// operator is looking — burying "stop listening" behind Settings → Audio
|
||||||
|
// meant a trip through two panels to mute a radio sitting in the same room.
|
||||||
|
const [listening, setListening] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNetwork) return;
|
||||||
|
let alive = true;
|
||||||
|
const ask = () => AudioMonitorActive().then((v) => { if (alive) setListening(!!v); }).catch(() => {});
|
||||||
|
ask();
|
||||||
|
const id = window.setInterval(ask, 2000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, [isNetwork]);
|
||||||
|
const toggleListening = () => {
|
||||||
|
const next = !listening;
|
||||||
|
setListening(next);
|
||||||
|
(next ? AudioStartMonitor() : Promise.resolve(AudioStopMonitor())).catch(() => setListening(!next));
|
||||||
|
};
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [st, setSt] = useState<IcomState>(ZERO);
|
const [st, setSt] = useState<IcomState>(ZERO);
|
||||||
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
||||||
const [tuning, setTuning] = useState(false);
|
const [tuning, setTuning] = useState(false);
|
||||||
|
// Band buttons: recall the radio's own band stacking register instead of
|
||||||
|
// sending a frequency picked here. Remembered per operator, not per session —
|
||||||
|
// it is a preference about how a button behaves, and having to set it again
|
||||||
|
// at every launch would make it not worth having.
|
||||||
|
const [bandStack, setBandStack] = useState(() => localStorage.getItem('opslog.icomBandStack') === '1');
|
||||||
|
const toggleBandStack = () => setBandStack((v) => {
|
||||||
|
const n = !v;
|
||||||
|
try { localStorage.setItem('opslog.icomBandStack', n ? '1' : '0'); } catch { /* private mode */ }
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
// Which register each band was last recalled from, so pressing the same band
|
||||||
|
// again walks 1 → 2 → 3 → 1, exactly as the radio's own band key does.
|
||||||
|
const bandRegRef = useRef<Record<string, number>>({});
|
||||||
const txRef = useRef(false);
|
const txRef = useRef(false);
|
||||||
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
|
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
|
||||||
|
|
||||||
@@ -604,6 +414,18 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
|
GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
|
||||||
};
|
};
|
||||||
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); };
|
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); };
|
||||||
|
|
||||||
|
// A band button. With the stacking registers on, ask the radio where the
|
||||||
|
// operator last was on that band; pressing the band it is already on steps to
|
||||||
|
// the next register, and the fixed frequency below is the fallback for a band
|
||||||
|
// or a model whose register the backend will not read — never a dead button.
|
||||||
|
const bandClick = (b: Band, here: boolean) => {
|
||||||
|
if (!bandStack) { SetCATFrequency(b.hz).catch(() => {}); return; }
|
||||||
|
const reg = here ? (bandRegRef.current[b.l] ?? 1) % 3 + 1 : 1;
|
||||||
|
IcomRecallBand(b.l, reg)
|
||||||
|
.then(() => { bandRegRef.current[b.l] = reg; load(); })
|
||||||
|
.catch(() => SetCATFrequency(b.hz).catch(() => {}));
|
||||||
|
};
|
||||||
// Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only
|
// Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only
|
||||||
// re-reads the cache; the backend also loads DSP on the first responsive read).
|
// re-reads the cache; the backend also loads DSP on the first responsive read).
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
@@ -627,7 +449,9 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
const toggleMox = () => {
|
const toggleMox = () => {
|
||||||
const next = !txRef.current;
|
const next = !txRef.current;
|
||||||
txRef.current = next;
|
txRef.current = next;
|
||||||
set({ transmitting: next }, () => IcomSetPTT(next));
|
// Through the console binding: on a network station the PC microphone
|
||||||
|
// rides with the PTT (silence otherwise); on USB it keys and nothing more.
|
||||||
|
set({ transmitting: next }, () => IcomConsolePTT(next));
|
||||||
};
|
};
|
||||||
|
|
||||||
const tune = async () => {
|
const tune = async () => {
|
||||||
@@ -670,8 +494,13 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
// other is TX (freq_hz); otherwise there's a single VFO (freq_hz).
|
// other is TX (freq_hz); otherwise there's a single VFO (freq_hz).
|
||||||
const split = !!cat?.split;
|
const split = !!cat?.split;
|
||||||
const mainHz: number = split ? (cat?.freq_rx_hz || 0) : (cat?.freq_hz || 0);
|
const mainHz: number = split ? (cat?.freq_rx_hz || 0) : (cat?.freq_hz || 0);
|
||||||
const subHz: number = split ? (cat?.freq_hz || 0) : 0;
|
// The sub receiver's dial is worth seeing whether or not split is on —
|
||||||
const curMode: string = cat?.mode || st.mode || '';
|
// in split the CAT state's TX freq is the authority, otherwise the panel's
|
||||||
|
// own sub_hz read.
|
||||||
|
const subHz: number = split ? (cat?.freq_hz || 0) : (st.sub_hz || 0);
|
||||||
|
// The panel's own mode first: it carries the sideband (USB/LSB) where the
|
||||||
|
// CAT state folds both into ADIF's SSB.
|
||||||
|
const curMode: string = st.mode || cat?.mode || '';
|
||||||
// Mode-dependent controls: VOX / speech-comp / mic are voice-only (hidden on
|
// Mode-dependent controls: VOX / speech-comp / mic are voice-only (hidden on
|
||||||
// CW and data); APF (audio peak filter) is CW-only. Fold USB/LSB into phone.
|
// CW and data); APF (audio peak filter) is CW-only. Fold USB/LSB into phone.
|
||||||
const um = curMode.toUpperCase();
|
const um = curMode.toUpperCase();
|
||||||
@@ -700,6 +529,14 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
rig's LAN server stays alive in standby, so both work. */}
|
rig's LAN server stays alive in standby, so both work. */}
|
||||||
{isNetwork && (
|
{isNetwork && (
|
||||||
<>
|
<>
|
||||||
|
<button type="button" onClick={toggleListening}
|
||||||
|
title={listening ? t('icmp.speakerOffHint') : t('icmp.speakerOnHint')}
|
||||||
|
className={cn('inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-bold',
|
||||||
|
listening
|
||||||
|
? 'border-primary/60 bg-primary/10 text-primary hover:bg-primary/20'
|
||||||
|
: 'border-border bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{listening ? <Volume2 className="size-3.5" /> : <VolumeX className="size-3.5" />}
|
||||||
|
</button>
|
||||||
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
|
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
|
||||||
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
|
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
|
||||||
<Power className="size-3.5" /> ON
|
<Power className="size-3.5" /> ON
|
||||||
@@ -741,9 +578,10 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
|
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
|
||||||
<div className="grid grid-cols-6 border-t border-border/60 divide-x divide-border/60">
|
<div className="grid border-t border-border/60 divide-x divide-border/60"
|
||||||
{MODES.map((m) => {
|
style={{ gridTemplateColumns: `repeat(${modesFor(st.model).length}, minmax(0, 1fr))` }}>
|
||||||
const on = modeMatches(m, curMode);
|
{modesFor(st.model).map((m) => {
|
||||||
|
const on = modeMatches(m, curMode, mainHz);
|
||||||
return (
|
return (
|
||||||
<button key={m} type="button" onClick={() => setMode(m)}
|
<button key={m} type="button" onClick={() => setMode(m)}
|
||||||
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
|
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
|
||||||
@@ -755,28 +593,41 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Live meters — always visible: S (RX, click → RST), Po in watts, SWR. */}
|
{/* Live meters — the SAME LED MeterBar every other console uses (Flex,
|
||||||
<div className="rounded-xl border border-border bg-card px-3 py-2.5 shadow-sm grid grid-cols-1 sm:grid-cols-3 gap-x-5 gap-y-2">
|
Elecraft, the amp cards): one instrument look across the app, per the
|
||||||
|
operator's "les consoles doivent se ressembler". S is clickable → RST. */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
{(() => { const sp = sParts(st.s_meter); return (
|
{(() => { const sp = sParts(st.s_meter); return (
|
||||||
<Meter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
|
<MeterBar label="S-METER" value={st.transmitting ? 0 : st.s_meter} lo={0} hi={100}
|
||||||
|
accent="#16a34a" segColor={sSegColor}
|
||||||
|
display={st.transmitting ? '—' : sp.label}
|
||||||
title={onReportRST ? t('rst.clickToFill') : undefined}
|
title={onReportRST ? t('rst.clickToFill') : undefined}
|
||||||
onClick={onReportRST ? () => onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
|
onClick={onReportRST ? () => { if (!st.transmitting) onReportRST(sMeterRST(sp.s, sp.over, st.mode)); } : undefined} />
|
||||||
); })()}
|
); })()}
|
||||||
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter} W`} />
|
{(() => {
|
||||||
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
|
if ((st.model ?? '').includes('7760')) {
|
||||||
|
const { w, defl } = icomWatts(st.power_meter);
|
||||||
|
return <MeterBar label="PWR" value={defl} lo={0} hi={100} accent="#0ea5e9" display={`${w} W`} />;
|
||||||
|
}
|
||||||
|
return <MeterBar label="PWR" value={st.power_meter} lo={0} hi={100} accent="#0ea5e9" display={`${st.power_meter} W`} />;
|
||||||
|
})()}
|
||||||
|
<MeterBar label="SWR" value={st.swr_meter > 0 ? 1 + st.swr_meter / 33.3 : 0} lo={1} hi={4} accent="#f59e0b"
|
||||||
|
display={st.swr_meter > 0 ? (1 + st.swr_meter / 33.3).toFixed(1) : '—'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spectrum panadapter (full width). */}
|
|
||||||
<ScopePanadapter />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
{/* Band buttons + antenna selection. */}
|
{/* Band buttons + antenna selection. */}
|
||||||
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||||||
|
<label className="flex items-center gap-1.5 mb-1.5 text-[11px] text-muted-foreground cursor-pointer select-none"
|
||||||
|
title={t('icmp.bandStackHint')}>
|
||||||
|
<input type="checkbox" checked={bandStack} onChange={toggleBandStack} className="accent-primary" />
|
||||||
|
{t('icmp.bandStack')}
|
||||||
|
</label>
|
||||||
<div className="grid grid-cols-5 gap-1.5">
|
<div className="grid grid-cols-5 gap-1.5">
|
||||||
{bandsFor(st.model).map((b) => {
|
{bandsFor(st.model).map((b) => {
|
||||||
const here = bandOfHz(mainHz) === b.l;
|
const here = bandOfHz(mainHz) === b.l;
|
||||||
return (
|
return (
|
||||||
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
<button key={b.l} type="button" onClick={() => bandClick(b, here)}
|
||||||
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
|
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
|
||||||
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
here
|
here
|
||||||
@@ -787,20 +638,22 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Row label={t('icmp.antenna')}>
|
{hasAntennaSelector(st.model) && (
|
||||||
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
<Row label={t('icmp.antenna')}>
|
||||||
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
|
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
||||||
</Row>
|
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
|
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
|
||||||
<Card icon={SlidersHorizontal} title={t('icmp.clarifiers')} accent="#8b5cf6">
|
<Card icon={SlidersHorizontal} title={t('icmp.clarifiers')} accent="#8b5cf6">
|
||||||
<ShiftRow label="RIT" accent="#8b5cf6" on={st.rit_on} hz={st.rit_hz}
|
<ShiftRow label="RIT" accent="#8b5cf6" on={st.rit_on} hz={st.rit_hz}
|
||||||
onToggle={() => set({ rit_on: !st.rit_on }, () => IcomSetRITOn(!st.rit_on))}
|
onToggle={() => set({ rit_on: !st.rit_on }, () => IcomSetRITOn(!st.rit_on))}
|
||||||
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
onSet={setRit} />
|
||||||
<ShiftRow label="ΔTX" accent="#f59e0b" on={st.xit_on} hz={st.rit_hz}
|
<ShiftRow label="ΔTX" accent="#f59e0b" on={st.xit_on} hz={st.rit_hz}
|
||||||
onToggle={() => set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))}
|
onToggle={() => set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))}
|
||||||
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
onSet={setRit} />
|
||||||
<p className="text-[11px] text-muted-foreground">{t('icmp.ritHint')}</p>
|
<p className="text-[11px] text-muted-foreground">{t('icmp.ritHint')}</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -808,9 +661,16 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
<Card icon={Mic} title={t('icmp.transmit')} accent="#ef4444">
|
<Card icon={Mic} title={t('icmp.transmit')} accent="#ef4444">
|
||||||
<Row label={t('icmp.power')}>
|
<Row label={t('icmp.power')}>
|
||||||
<Slider value={st.rf_power} accent="#ef4444" onChange={(v) => set({ rf_power: v }, () => IcomSetRFPower(v))} />
|
<Slider value={st.rf_power} accent="#ef4444" onChange={(v) => set({ rf_power: v }, () => IcomSetRFPower(v))} />
|
||||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_power}</span>
|
{/* PC is a percentage of the rig's rated power; on a 200 W rig the
|
||||||
|
operator thinks in watts, so say it in watts there. */}
|
||||||
|
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">
|
||||||
|
{(st.model ?? '').includes('7760') ? `${st.rf_power * 2} W` : st.rf_power}
|
||||||
|
</span>
|
||||||
</Row>
|
</Row>
|
||||||
{isPhone && (
|
{/* Not phone-only: on USB-D the same control still sets what the radio
|
||||||
|
transmits at, and hiding it left an operator who lives in FT8 with
|
||||||
|
no mic gain at all. */}
|
||||||
|
{(
|
||||||
<Row label={t('icmp.mic')}>
|
<Row label={t('icmp.mic')}>
|
||||||
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
|
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
|
||||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
|
||||||
@@ -823,6 +683,9 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
{tx ? 'TX ON' : 'MOX'}
|
{tx ? 'TX ON' : 'MOX'}
|
||||||
</button>
|
</button>
|
||||||
<Chip label="SPLIT" on={st.split} onClick={() => set({ split: !st.split }, () => IcomSetSplit(!st.split))} />
|
<Chip label="SPLIT" on={st.split} onClick={() => set({ split: !st.split }, () => IcomSetSplit(!st.split))} />
|
||||||
|
{/* Tuner IN/OUT — TUNE below starts a cycle but could never take the
|
||||||
|
tuner back out of line. */}
|
||||||
|
<Chip label="ATU" on={!!st.atu_on} onClick={() => set({ atu_on: !st.atu_on } as any, () => IcomSetATU(!st.atu_on))} />
|
||||||
<button type="button" onClick={tune} disabled={tuning}
|
<button type="button" onClick={tune} disabled={tuning}
|
||||||
className={cn('w-14 shrink-0 px-2 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
className={cn('w-14 shrink-0 px-2 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
tuning ? 'bg-warning border-warning text-warning-foreground animate-pulse' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
tuning ? 'bg-warning border-warning text-warning-foreground animate-pulse' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { WheelRange } from '@/components/WheelRange';
|
||||||
|
|
||||||
|
// LevelRow — a named level with a slider, a value you can type into, and ±.
|
||||||
|
//
|
||||||
|
// Shared, like ShiftRow, and for the same complaint: the consoles each drew
|
||||||
|
// their levels their own way. This is the wide shape — one row per level, the
|
||||||
|
// slider taking the width it needs — rather than two half-width sliders side by
|
||||||
|
// side, which is what "c'est laid et elles sont toutes petites" was about.
|
||||||
|
//
|
||||||
|
// Four ways to move it, so nobody has to learn ours: drag, wheel over the
|
||||||
|
// track, ± for one step, or click the number and type. Typing matters for the
|
||||||
|
// levels TCI reports in real units — a squelch at -95 dBm is a value an
|
||||||
|
// operator knows, not a position to hunt for with a mouse.
|
||||||
|
export function LevelRow({
|
||||||
|
label, value, min = 0, max = 100, step = 1, unit = '', accent, disabled, onSet,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
unit?: string;
|
||||||
|
accent?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onSet: (v: number) => void;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState<string | null>(null);
|
||||||
|
const clamp = (v: number) => Math.max(min, Math.min(max, v));
|
||||||
|
const commit = (raw: string) => {
|
||||||
|
setEditing(null);
|
||||||
|
const v = parseInt(raw.replace(/[^0-9+-]/g, ''), 10);
|
||||||
|
if (!Number.isNaN(v)) onSet(clamp(v));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="w-24 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<WheelRange
|
||||||
|
min={min} max={max} step={step} value={value} disabled={disabled} accent={accent}
|
||||||
|
onChange={onSet}
|
||||||
|
className="h-2.5 flex-1 [&::-webkit-slider-thumb]:size-4"
|
||||||
|
/>
|
||||||
|
<div className={cn('flex items-center gap-0.5 shrink-0', disabled && 'opacity-40')}>
|
||||||
|
<button type="button" disabled={disabled} onClick={() => onSet(clamp(value - step))}
|
||||||
|
className="px-1.5 text-sm font-bold text-muted-foreground hover:text-foreground disabled:opacity-40">−</button>
|
||||||
|
{editing !== null ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={editing}
|
||||||
|
onChange={(e) => setEditing(e.target.value)}
|
||||||
|
onBlur={(e) => commit(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') commit((e.target as HTMLInputElement).value);
|
||||||
|
else if (e.key === 'Escape') setEditing(null);
|
||||||
|
}}
|
||||||
|
className="w-14 rounded border border-border bg-background px-1 text-right text-xs font-mono tabular-nums outline-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button type="button" disabled={disabled} onClick={() => setEditing(String(value))}
|
||||||
|
className="w-14 text-right text-xs font-mono tabular-nums hover:text-primary disabled:cursor-default">
|
||||||
|
{value}{unit}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" disabled={disabled} onClick={() => onSet(clamp(value + step))}
|
||||||
|
className="px-1.5 text-sm font-bold text-muted-foreground hover:text-foreground disabled:opacity-40">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'leaflet/dist/leaflet.css';
|
|||||||
import { nightPolygon } from '../lib/greyline';
|
import { nightPolygon } from '../lib/greyline';
|
||||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { formatDistance } from '@/lib/units';
|
||||||
|
|
||||||
// Persisted free-pan view of the world map (when auto-zoom is off).
|
// Persisted free-pan view of the world map (when auto-zoom is off).
|
||||||
function loadMapView(): { lat: number; lon: number; zoom: number } | null {
|
function loadMapView(): { lat: number; lon: number; zoom: number } | null {
|
||||||
@@ -42,8 +43,18 @@ function unwrapLon(ring: [number, number][]): [number, number][] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CARTO_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
// CARTO IS GONE, and it went the same way OpenStreetMap did.
|
||||||
const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
//
|
||||||
|
// Their key-free basemaps now serve tiles stamped "API KEY REQUIRED" across
|
||||||
|
// the middle of the map — reported by an operator whose Light and Voyager views
|
||||||
|
// came up watermarked. They still answer 200 with a real image, which is why
|
||||||
|
// nothing here could detect it: the map looks like it is working and simply
|
||||||
|
// says otherwise in large grey letters.
|
||||||
|
//
|
||||||
|
// So both are replaced by Esri layers, the same provider already behind Street
|
||||||
|
// and Satellite. That is deliberate: a second key-free provider is a second
|
||||||
|
// chance to be cut off, and this one has already been serving the other two
|
||||||
|
// views for as long as OpsLog has had a map.
|
||||||
|
|
||||||
// NOT tile.openstreetmap.org. Those are OpenStreetMap's VOLUNTEER-run servers,
|
// NOT tile.openstreetmap.org. Those are OpenStreetMap's VOLUNTEER-run servers,
|
||||||
// and their usage policy does not cover a desktop application handed to an
|
// and their usage policy does not cover a desktop application handed to an
|
||||||
@@ -55,17 +66,33 @@ const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
|||||||
// still credits OpenStreetMap) and Esri. Anything added here later must be
|
// still credits OpenStreetMap) and Esri. Anything added here later must be
|
||||||
// checked the same way — a free tile URL is not the same thing as a tile URL we
|
// checked the same way — a free tile URL is not the same thing as a tile URL we
|
||||||
// are allowed to ship.
|
// are allowed to ship.
|
||||||
const ESRI_STREET = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}';
|
const ESRI = 'https://server.arcgisonline.com/ArcGIS/rest/services';
|
||||||
|
const ESRI_STREET = ESRI + '/World_Street_Map/MapServer/tile/{z}/{y}/{x}';
|
||||||
const ESRI_STREET_ATTR = 'Tiles © Esri — Source: Esri, HERE, Garmin, © OpenStreetMap contributors';
|
const ESRI_STREET_ATTR = 'Tiles © Esri — Source: Esri, HERE, Garmin, © OpenStreetMap contributors';
|
||||||
|
|
||||||
|
// The plain background the old Carto "Light" was: a pale canvas that lets the
|
||||||
|
// paths and the spots carry the colour. Its names come on a separate
|
||||||
|
// transparent layer, the same arrangement the satellite view already uses.
|
||||||
|
const ESRI_LIGHT = ESRI + '/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}';
|
||||||
|
const ESRI_LIGHT_LABELS = ESRI + '/Canvas/World_Light_Gray_Reference/MapServer/tile/{z}/{y}/{x}';
|
||||||
|
const ESRI_LIGHT_ATTR = 'Tiles © Esri — Esri, DeLorme, NAVTEQ';
|
||||||
|
|
||||||
// Selectable basemaps for the world (great-circle) map. All key-free and all
|
// Selectable basemaps for the world (great-circle) map. All key-free and all
|
||||||
// LABELLED (country/continent names). `labelsUrl` adds a transparent place-name
|
// LABELLED (country/continent names). `labelsUrl` adds a transparent place-name
|
||||||
// overlay on top of an imagery basemap (so satellite keeps its names too).
|
// overlay on top of an imagery basemap (so satellite keeps its names too).
|
||||||
export type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite';
|
export type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite';
|
||||||
export const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string }> = {
|
// maxNativeZoom is where a layer RUNS OUT of tiles. Leaflet then upscales the
|
||||||
light: { label: 'Light', url: CARTO_LIGHT, attr: CARTO_ATTR, subdomains: 'abcd' },
|
// last real one instead of asking for a level that does not exist, which is the
|
||||||
voyager: { label: 'Voyager', url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
// difference between a slightly soft map and a blank grey square: the light
|
||||||
attr: CARTO_ATTR, subdomains: 'abcd' },
|
// canvas stops at 16 where the imagery and the topo go on to 19.
|
||||||
|
export const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string; maxNativeZoom?: number }> = {
|
||||||
|
light: { label: 'Light', url: ESRI_LIGHT, attr: ESRI_LIGHT_ATTR, labelsUrl: ESRI_LIGHT_LABELS, maxNativeZoom: 16 },
|
||||||
|
// The key is still "voyager" so that everyone who chose it keeps their view
|
||||||
|
// rather than being silently moved to another one. What it draws is Esri's
|
||||||
|
// topographic map: terrain under the labels, which is what made Voyager worth
|
||||||
|
// choosing over the plain canvas.
|
||||||
|
voyager: { label: 'Topo', url: ESRI + '/World_Topo_Map/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
attr: 'Tiles © Esri — Esri, HERE, Garmin, USGS, NGA, © OpenStreetMap contributors' },
|
||||||
street: { label: 'Street', url: ESRI_STREET, attr: ESRI_STREET_ATTR },
|
street: { label: 'Street', url: ESRI_STREET, attr: ESRI_STREET_ATTR },
|
||||||
satellite: { label: 'Satellite', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
satellite: { label: 'Satellite', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
attr: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics',
|
attr: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics',
|
||||||
@@ -100,7 +127,8 @@ export function addBasemap(
|
|||||||
if (labels.current) { m.removeLayer(labels.current); labels.current = null; }
|
if (labels.current) { m.removeLayer(labels.current); labels.current = null; }
|
||||||
const bm = BASEMAPS[key];
|
const bm = BASEMAPS[key];
|
||||||
const tileOpts: L.TileLayerOptions = {
|
const tileOpts: L.TileLayerOptions = {
|
||||||
maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
maxZoom: 19, maxNativeZoom: bm.maxNativeZoom ?? 19,
|
||||||
|
updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
||||||
noWrap: !!opts?.noWrap,
|
noWrap: !!opts?.noWrap,
|
||||||
...(opts?.bounds ? { bounds: opts.bounds } : {}),
|
...(opts?.bounds ? { bounds: opts.bounds } : {}),
|
||||||
};
|
};
|
||||||
@@ -342,8 +370,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
|
|
||||||
if (autoZoom) {
|
if (autoZoom) {
|
||||||
if (from && to && arcPts) {
|
if (from && to && arcPts) {
|
||||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
// Latitudes clamped to Mercator's edge (±85°): the arc to a polar
|
||||||
arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
// entity (Franz Josef Land) peaks near 88°N, and fitting the raw
|
||||||
|
// points framed a band of tile-less white above the top of the world.
|
||||||
|
// The line itself still draws to wherever it goes — only the CAMERA
|
||||||
|
// stays where there is a map to show.
|
||||||
|
const clamp = (lat: number) => Math.max(-85, Math.min(85, lat));
|
||||||
|
const bounds = L.latLngBounds([[clamp(from.lat), from.lon], [clamp(to.lat), to.lon]]);
|
||||||
|
arcPts.forEach((p) => bounds.extend([clamp(p[0]), p[1]] as L.LatLngExpression));
|
||||||
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||||
} else if (to) {
|
} else if (to) {
|
||||||
wm.setView([to.lat, to.lon], 3);
|
wm.setView([to.lat, to.lon], 3);
|
||||||
@@ -419,8 +453,8 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
</button>
|
</button>
|
||||||
{path && (
|
{path && (
|
||||||
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
||||||
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
<div><span className="text-muted-foreground">Dist</span> {formatDistance(path.distanceShort)}
|
||||||
<span className="text-muted-foreground"> · LP</span> {Math.round(path.distanceLong).toLocaleString()} km</div>
|
<span className="text-muted-foreground"> · LP</span> {formatDistance(path.distanceLong)}</div>
|
||||||
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
|
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
|
||||||
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
|
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -448,8 +482,12 @@ export function LocatorMap({ toGrid, toLabel }: LocatorProps) {
|
|||||||
// single ring of buffer tiles. Requesting tiles as fast as the pointer
|
// single ring of buffer tiles. Requesting tiles as fast as the pointer
|
||||||
// moves is what gets an app blocked, and the next provider is under no
|
// moves is what gets an app blocked, and the next provider is under no
|
||||||
// more obligation to tolerate it than the last one was.
|
// more obligation to tolerate it than the last one was.
|
||||||
L.tileLayer(CARTO_LIGHT, {
|
L.tileLayer(ESRI_LIGHT, {
|
||||||
attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19,
|
attribution: ESRI_LIGHT_ATTR, maxZoom: 19, maxNativeZoom: 16,
|
||||||
|
updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
||||||
|
}).addTo(m);
|
||||||
|
L.tileLayer(ESRI_LIGHT_LABELS, {
|
||||||
|
maxZoom: 19, maxNativeZoom: 16,
|
||||||
updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
||||||
}).addTo(m);
|
}).addTo(m);
|
||||||
locatorOverlay.current = L.layerGroup().addTo(m);
|
locatorOverlay.current = L.layerGroup().addTo(m);
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
// PSKReporterPanel — can the station I am about to call actually hear me?
|
||||||
|
//
|
||||||
|
// The decodes list to the left says who is transmitting. It cannot say anything
|
||||||
|
// about the other direction, and on FT8 that is the whole question: the DX's
|
||||||
|
// pileup is invisible from here, and a station whose region is not open to
|
||||||
|
// yours will not hear you however many times you call.
|
||||||
|
//
|
||||||
|
// Every number here comes from PSK Reporter — reports uploaded by ordinary
|
||||||
|
// stations saying "I decoded X" — over a five-minute window. Nothing is
|
||||||
|
// inferred and nothing is remembered: when the window empties the panel says it
|
||||||
|
// does not know, which is the honest answer and the reason each block also says
|
||||||
|
// what it is measuring.
|
||||||
|
//
|
||||||
|
// The backend (internal/pskrtgt) does the analysis; this draws it and polls.
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Activity, ChevronRight } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { GetPSKAnalysis, SetPSKTarget } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
export type PSKEntry = {
|
||||||
|
call: string;
|
||||||
|
grid?: string;
|
||||||
|
snr: number;
|
||||||
|
offset_hz: number;
|
||||||
|
age_sec: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PSKAnalysis = {
|
||||||
|
target?: string;
|
||||||
|
mode?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
online: boolean;
|
||||||
|
spots: number;
|
||||||
|
he_me: boolean;
|
||||||
|
he_me_seconds: number;
|
||||||
|
he_me_snr: number;
|
||||||
|
he_me_offset_hz: number;
|
||||||
|
target_uploads: boolean;
|
||||||
|
target_grid?: string;
|
||||||
|
near_him_count: number;
|
||||||
|
near_him_top?: PSKEntry[];
|
||||||
|
from_my_area_count: number;
|
||||||
|
from_my_area_top?: PSKEntry[];
|
||||||
|
path_open: boolean;
|
||||||
|
heard_by_count: number;
|
||||||
|
heard_near_me: number;
|
||||||
|
heard_near_me_top?: PSKEntry[];
|
||||||
|
decoded_by_count: number;
|
||||||
|
decoded_by_top?: PSKEntry[];
|
||||||
|
decoded_by_calls?: string[];
|
||||||
|
pileup_count: number;
|
||||||
|
dial_hz: number;
|
||||||
|
ceiling_hz: number;
|
||||||
|
decodes_in_window: number;
|
||||||
|
bins?: { offset_hz: number; count: number; avg_snr: number }[];
|
||||||
|
suggested_offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// The station to analyse and the mode it was heard on. Set by clicking a
|
||||||
|
// decode, or by whoever the digital application says it is calling.
|
||||||
|
target: string;
|
||||||
|
mode?: string;
|
||||||
|
// The operator's own dial, which is what turns a report's frequency into an
|
||||||
|
// audio offset. Without it the passband block has nothing to say.
|
||||||
|
dialHz?: number;
|
||||||
|
// The local decodes, for "callers you hear": stations WE are decoding that
|
||||||
|
// are calling the same DX. That is the competition measured at this end,
|
||||||
|
// which no amount of PSK Reporter data can show.
|
||||||
|
callers: number;
|
||||||
|
callerCalls?: string[];
|
||||||
|
onCollapse: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The passband strip: 60 Hz bins, drawn from 200 Hz to 4000 Hz. The bin edges
|
||||||
|
// have to match the backend's alignment exactly — it keys them on multiples of
|
||||||
|
// 60 from zero, so a strip starting at 200 would ask for edges that never
|
||||||
|
// exist and draw an empty histogram over a busy passband.
|
||||||
|
const LO = 200, HI = 4000, STEP = 60;
|
||||||
|
const FIRST_EDGE = Math.floor(LO / STEP) * STEP;
|
||||||
|
const COLS = Math.floor((HI - FIRST_EDGE) / STEP);
|
||||||
|
|
||||||
|
export function PSKReporterPanel({ target, mode, dialHz, callers, callerCalls, onCollapse }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [a, setA] = useState<PSKAnalysis | null>(null);
|
||||||
|
|
||||||
|
// One second, matching the panel's own claim about how fresh it is. The call
|
||||||
|
// is a snapshot of an in-memory window — no query and no network of its own.
|
||||||
|
useEffect(() => {
|
||||||
|
let stop = false;
|
||||||
|
const tick = () => {
|
||||||
|
GetPSKAnalysis().then((r) => { if (!stop) setA(r as unknown as PSKAnalysis); }).catch(() => {});
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1000);
|
||||||
|
return () => { stop = true; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The target is re-asserted rather than sent once. The backend treats an
|
||||||
|
// unchanged callsign as a no-op, and this way a broker that dropped while
|
||||||
|
// nobody was looking comes back on its own instead of leaving a panel that
|
||||||
|
// is permanently, silently empty.
|
||||||
|
useEffect(() => {
|
||||||
|
SetPSKTarget(target ?? '', mode ?? '', dialHz ?? 0).catch(() => {});
|
||||||
|
if (!target) return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
SetPSKTarget(target, mode ?? '', dialHz ?? 0).catch(() => {});
|
||||||
|
}, 15000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [target, mode, dialHz]);
|
||||||
|
|
||||||
|
const bins = a?.bins ?? [];
|
||||||
|
const maxCount = useMemo(() => bins.reduce((m, b) => Math.max(m, b.count || 0), 0) || 1, [bins]);
|
||||||
|
const byOffset = useMemo(() => {
|
||||||
|
const m = new Map<number, { count: number; avg_snr: number }>();
|
||||||
|
for (const b of bins) m.set(b.offset_hz, b);
|
||||||
|
return m;
|
||||||
|
}, [bins]);
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
const out: { edge: number; count: number; snr: number | null }[] = [];
|
||||||
|
for (let i = 0; i < COLS; i++) {
|
||||||
|
const edge = FIRST_EDGE + i * STEP;
|
||||||
|
const b = byOffset.get(edge);
|
||||||
|
out.push({ edge, count: b?.count ?? 0, snr: b?.avg_snr ?? null });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [byOffset]);
|
||||||
|
|
||||||
|
// Confirmed pileup: a station we hear calling this DX that the DX has also
|
||||||
|
// decoded. Two independent pieces of evidence, so it is the one number here
|
||||||
|
// that is not a proxy for anything.
|
||||||
|
const confirmed = useMemo(() => {
|
||||||
|
const heard = new Set((a?.decoded_by_calls ?? []).map((c) => c.toUpperCase()));
|
||||||
|
return (callerCalls ?? []).filter((c) => heard.has(c.toUpperCase())).length;
|
||||||
|
}, [a?.decoded_by_calls, callerCalls]);
|
||||||
|
|
||||||
|
const snr = (v: number) => `${v > 0 ? '+' : ''}${v}`;
|
||||||
|
|
||||||
|
const Tile = ({ label, value, foot, tone, title }: {
|
||||||
|
label: string; value: number | string; foot: string; tone: string; title?: string;
|
||||||
|
}) => (
|
||||||
|
<div className="px-2 py-1.5 rounded-md bg-muted/40 border border-border/60" title={title}>
|
||||||
|
<div className="text-[9px] uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||||
|
<div className={cn('text-lg font-bold leading-tight', tone)}>{value}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{foot}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[340px] shrink-0 flex flex-col min-h-0 border-l border-border bg-card">
|
||||||
|
{/* Header: what is being watched, and whether the feed is actually up. A
|
||||||
|
panel full of zeros means one of two very different things. */}
|
||||||
|
<div className="flex items-center gap-2 px-2.5 py-2 border-b border-border shrink-0">
|
||||||
|
<Activity className="size-4 text-primary shrink-0" />
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t('psk.title')}
|
||||||
|
</span>
|
||||||
|
{target && <span className="text-xs font-mono text-foreground truncate">→ {target}</span>}
|
||||||
|
<span className="ml-auto flex items-center gap-2 text-[10px] shrink-0">
|
||||||
|
{a?.target && a.spots > 0 && (
|
||||||
|
<span className="text-muted-foreground" title={t('psk.spotsTip')}>{t('psk.spots', { n: a.spots })}</span>
|
||||||
|
)}
|
||||||
|
{a?.enabled === false
|
||||||
|
? <span className="text-muted-foreground">{t('psk.off')}</span>
|
||||||
|
: a?.online
|
||||||
|
? <span className="text-success">● {t('psk.online')}</span>
|
||||||
|
: <span className="text-muted-foreground">○ {t('psk.offline')}</span>}
|
||||||
|
<button type="button" onClick={onCollapse} title={t('psk.hide')}
|
||||||
|
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground">
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto px-2.5 py-2 space-y-3">
|
||||||
|
{a?.enabled === false ? (
|
||||||
|
<p className="text-xs text-muted-foreground italic">{t('psk.enableHint')}</p>
|
||||||
|
) : !target ? (
|
||||||
|
<p className="text-xs text-muted-foreground italic">{t('psk.pickHint')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ── The answer ──────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn('shrink-0 size-8 rounded-full border flex items-center justify-center text-base',
|
||||||
|
a?.he_me ? 'bg-success/20 border-success/50 text-success'
|
||||||
|
: a?.path_open ? 'bg-warning/20 border-warning/50 text-warning'
|
||||||
|
: 'bg-muted border-border text-muted-foreground')}>
|
||||||
|
{a?.he_me ? '✓' : a?.path_open ? '≈' : '·'}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{a?.he_me ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-semibold text-success">{t('psk.heardYou', { s: a.he_me_seconds })}</div>
|
||||||
|
<div className="text-[11px] font-mono text-muted-foreground">
|
||||||
|
{snr(a.he_me_snr)} dB{a.he_me_offset_hz > 0 ? ` @ +${a.he_me_offset_hz} Hz` : ''}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : a?.path_open ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-semibold text-warning">{t('psk.pathOpen')}</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">{t('psk.pathOpenSub', { n: a.from_my_area_count })}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-semibold text-muted-foreground">{t('psk.notYet')}</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">{t('psk.notYetSub')}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Your signal reported next to him. Only when he has not decoded
|
||||||
|
you himself — that is strictly stronger evidence, and two
|
||||||
|
banners saying the same thing differently is noise. */}
|
||||||
|
{!a?.he_me && (a?.near_him_count ?? 0) > 0 && a?.target_grid && (
|
||||||
|
<div className="px-2 py-1.5 rounded-md bg-info/10 border border-info/30">
|
||||||
|
<div className="flex items-baseline justify-between gap-2 mb-0.5">
|
||||||
|
<span className="text-[10px] uppercase tracking-wide font-semibold text-info">
|
||||||
|
✓ {t('psk.nearHim', { g: a.target_grid })}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{t('psk.nRx', { n: a.near_him_count })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-2 font-mono text-[11px]">
|
||||||
|
{(a.near_him_top ?? []).map((h) => (
|
||||||
|
<span key={h.call} className="text-info" title={`${h.call} ${h.grid ?? ''} · ${h.age_sec}s`}>
|
||||||
|
{h.call} <span className="text-muted-foreground">{snr(h.snr)}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── The four numbers ────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<Tile label={t('psk.tFromArea')} value={a?.from_my_area_count ?? 0} foot={t('psk.tFromAreaFoot')}
|
||||||
|
tone="text-success"
|
||||||
|
title={(a?.from_my_area_top ?? []).map((h) => `${h.call} (${h.grid ?? '?'}) ${snr(h.snr)}`).join(' · ')} />
|
||||||
|
<Tile label={t('psk.tPileup')} value={a?.pileup_count ?? 0} foot={t('psk.tPileupFoot')}
|
||||||
|
tone="text-primary" title={t('psk.tPileupTip')} />
|
||||||
|
<Tile label={t('psk.tHeardNear')} value={a?.heard_near_me ?? 0} foot={t('psk.tHeardNearFoot')}
|
||||||
|
tone="text-info"
|
||||||
|
title={t('psk.tHeardNearTip', { n: a?.heard_by_count ?? 0 })} />
|
||||||
|
<Tile label={t('psk.tCallers')} value={confirmed > 0 ? `${callers} (${confirmed})` : callers}
|
||||||
|
foot={confirmed > 0 ? t('psk.tCallersFootConf') : t('psk.tCallersFoot')}
|
||||||
|
tone="text-warning" title={t('psk.tCallersTip')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The one thing that turns an empty panel from a verdict into a
|
||||||
|
missing measurement. */}
|
||||||
|
{a?.target_uploads ? (
|
||||||
|
<div className="text-[11px] text-success">✓ {t('psk.uploads')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-2 py-1.5 rounded-md bg-warning/10 border border-warning/30 text-[11px] text-warning">
|
||||||
|
⚠ {t('psk.noUploads', { c: target })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Who near you he is hearing ──────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground mb-0.5">{t('psk.fromAreaList')}</div>
|
||||||
|
{(a?.from_my_area_top ?? []).length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
|
||||||
|
{(a?.from_my_area_top ?? []).slice(0, 4).map((h) => (
|
||||||
|
<div key={h.call} className="flex items-baseline gap-2 truncate"
|
||||||
|
title={t('psk.rowTip', { c: h.call, g: h.grid ?? '?', s: h.age_sec, d: snr(h.snr) })}>
|
||||||
|
<span className="font-semibold text-foreground w-20 truncate">{h.call}</span>
|
||||||
|
<span className="text-muted-foreground w-12">({(h.grid ?? '?').slice(0, 4)})</span>
|
||||||
|
<span className="text-success w-14">{snr(h.snr)} dB</span>
|
||||||
|
{h.offset_hz > 0 && h.offset_hz < 10000 && (
|
||||||
|
<span className="ml-auto text-muted-foreground whitespace-nowrap">@ +{h.offset_hz} Hz</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[11px] text-muted-foreground italic">{t('psk.fromAreaEmpty')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── His passband ────────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{t('psk.passband')}</span>
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground">
|
||||||
|
{(a?.ceiling_hz ?? 0) > 0
|
||||||
|
? t('psk.ceiling', { hz: a!.ceiling_hz, n: a!.decodes_in_window })
|
||||||
|
: (a?.decodes_in_window ?? 0) > 0 ? t('psk.noDial') : t('psk.noDecodes')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex items-end gap-px h-10 rounded bg-muted/40 px-1 py-0.5 overflow-hidden">
|
||||||
|
{columns.map((c) => {
|
||||||
|
const ratio = c.count / maxCount;
|
||||||
|
return (
|
||||||
|
<div key={c.edge}
|
||||||
|
className={cn('flex-1 min-w-0 rounded-sm',
|
||||||
|
c.count === 0 ? 'bg-border'
|
||||||
|
: ratio > 0.66 ? 'bg-primary'
|
||||||
|
: ratio > 0.33 ? 'bg-primary/70' : 'bg-primary/40')}
|
||||||
|
style={{ height: `${Math.max(2, Math.round(ratio * 36))}px` }}
|
||||||
|
title={`${c.edge}-${c.edge + STEP} Hz · ${c.count}${c.snr !== null ? ` @ ${c.snr.toFixed(0)} dB` : ''}`} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(a?.suggested_offset ?? 0) > 0 && (
|
||||||
|
<div className="absolute top-0 bottom-0 w-0.5 bg-success pointer-events-none"
|
||||||
|
style={{ left: `${((a!.suggested_offset - LO) / (HI - LO)) * 100}%`, boxShadow: '0 0 4px currentColor' }}
|
||||||
|
title={t('psk.tryOffset', { hz: a!.suggested_offset })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative h-3 mt-0.5 text-[9px] font-mono text-muted-foreground">
|
||||||
|
{[1000, 2000, 3000, 4000].map((hz) => (
|
||||||
|
<span key={hz} className="absolute whitespace-nowrap"
|
||||||
|
style={{ left: `${((hz - LO) / (HI - LO)) * 100}%`, transform: `translateX(${hz === HI ? '-100%' : '-50%'})` }}>
|
||||||
|
{hz}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(a?.suggested_offset ?? 0) > 0 && (
|
||||||
|
<div className="text-center text-[11px] font-mono text-success mt-0.5">
|
||||||
|
🎯 {t('psk.tryOffset', { hz: a!.suggested_offset })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadFullLogHamQTH, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
@@ -31,7 +31,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
type Confirmation = {
|
type Confirmation = {
|
||||||
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
callsign: string; station?: string; qso_date: string; band: string; mode: string; country: string;
|
||||||
new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
|
new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ const SERVICES = [
|
|||||||
{ v: 'eqsl', label: 'eQSL.cc' },
|
{ v: 'eqsl', label: 'eQSL.cc' },
|
||||||
{ v: 'lotw', label: 'LoTW' },
|
{ v: 'lotw', label: 'LoTW' },
|
||||||
{ v: 'hamlog', label: 'HAMLOG.online' },
|
{ v: 'hamlog', label: 'HAMLOG.online' },
|
||||||
|
{ v: 'hamqth', label: 'HamQTH' },
|
||||||
{ v: 'pota', label: 'POTA hunter log' },
|
{ v: 'pota', label: 'POTA hunter log' },
|
||||||
{ v: 'paper', label: 'Paper QSL' },
|
{ v: 'paper', label: 'Paper QSL' },
|
||||||
];
|
];
|
||||||
@@ -255,6 +256,15 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
|||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [addNotFound, setAddNotFound] = useState(false);
|
const [addNotFound, setAddNotFound] = useState(false);
|
||||||
|
// LoTW only: pull the whole account rather than this profile's callsign.
|
||||||
|
const [lotwAllCalls, setLotwAllCalls] = useState(false);
|
||||||
|
// LoTW only: ask for the QSL dates and station details. Ten times slower to
|
||||||
|
// build, so it is a choice rather than the default it used to be.
|
||||||
|
const [lotwDetail, setLotwDetail] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
GetLoTWDownloadAllCalls().then((v: boolean) => setLotwAllCalls(!!v)).catch(() => {});
|
||||||
|
GetLoTWQSLDetail().then((v: boolean) => setLotwDetail(!!v)).catch(() => {});
|
||||||
|
}, []);
|
||||||
// Download date window: 'last' = incremental since last pull, 'date' = from a
|
// Download date window: 'last' = incremental since last pull, 'date' = from a
|
||||||
// chosen date, 'all' = everything.
|
// chosen date, 'all' = everything.
|
||||||
const [sinceMode, setSinceMode] = useState<'last' | 'date' | 'all'>('last');
|
const [sinceMode, setSinceMode] = useState<'last' | 'date' | 'all'>('last');
|
||||||
@@ -434,6 +444,18 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
|||||||
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||||
{t('qslm.search')}
|
{t('qslm.search')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* The station's QRZ page, one click away: writing a card means
|
||||||
|
reading the address, the manager and whether they even want
|
||||||
|
paper, and all three are on that page. */}
|
||||||
|
<Button size="sm" variant="outline" className="h-8" disabled={!paperCall.trim()}
|
||||||
|
title={t('qslm.qrzTitle')}
|
||||||
|
onClick={() => {
|
||||||
|
const c = paperCall.trim().toUpperCase();
|
||||||
|
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch(() => {});
|
||||||
|
}}>
|
||||||
|
<ExternalLink className="size-3.5" />
|
||||||
|
QRZ
|
||||||
|
</Button>
|
||||||
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.paperHint')}</span>
|
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.paperHint')}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -598,6 +620,10 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
|||||||
<thead className="sticky top-0 bg-card">
|
<thead className="sticky top-0 bg-card">
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
<tr className="text-left text-muted-foreground border-b border-border">
|
||||||
<th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th><th className="py-1.5 px-2">{t('qslm.thCallsign')}</th>
|
<th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th><th className="py-1.5 px-2">{t('qslm.thCallsign')}</th>
|
||||||
|
{/* Which of the operator's callsigns the contact was made
|
||||||
|
under. Only worth a column when the list can hold more than
|
||||||
|
one — see "All my callsigns" on the download. */}
|
||||||
|
{shownConfs.some((c) => c.station) && <th className="py-1.5 px-2">{t('qslm.thStation')}</th>}
|
||||||
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thMode')}</th>
|
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thMode')}</th>
|
||||||
<th className="py-1.5 px-2">{t('qslm.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
|
<th className="py-1.5 px-2">{t('qslm.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -607,6 +633,9 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
|||||||
<tr key={i} className="border-b border-border/40">
|
<tr key={i} className="border-b border-border/40">
|
||||||
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
|
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
|
||||||
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
|
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
|
||||||
|
{shownConfs.some((x) => x.station) && (
|
||||||
|
<td className="py-1 px-2 font-mono text-muted-foreground">{c.station ?? ''}</td>
|
||||||
|
)}
|
||||||
<td className="py-1 px-2">{c.band}</td>
|
<td className="py-1 px-2">{c.band}</td>
|
||||||
<td className="py-1 px-2">{c.mode}</td>
|
<td className="py-1 px-2">{c.mode}</td>
|
||||||
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
||||||
@@ -694,7 +723,25 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
|||||||
{service !== 'pota' && service !== 'paper' && (
|
{service !== 'pota' && service !== 'paper' && (
|
||||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{service === 'hamlog' ? (
|
{service === 'hamqth' ? (
|
||||||
|
// HamQTH's file endpoint REPLACES the remote log — its own
|
||||||
|
// documentation is explicit that partial uploads do not exist. So
|
||||||
|
// it is offered as its own deliberate act, never as the batch path
|
||||||
|
// behind "send these": that would delete everything not selected.
|
||||||
|
<Button variant="outline" size="sm" disabled={busy}
|
||||||
|
title={t('qslm.hqFullTitle')}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm(t('qslm.hqFullConfirm'))) return;
|
||||||
|
// Same three lines every other action here runs: without
|
||||||
|
// setShowLog the whole upload reported itself into a panel
|
||||||
|
// nobody was showing, and the tab sat on "Pick a service".
|
||||||
|
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
||||||
|
try { await UploadFullLogHamQTH(); }
|
||||||
|
catch (e: any) { setBusy(false); setLogLines((l) => [...l, String(e?.message ?? e)]); }
|
||||||
|
}}>
|
||||||
|
<UploadCloud className="size-3.5" /> {t('qslm.hqFull')}
|
||||||
|
</Button>
|
||||||
|
) : service === 'hamlog' ? (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
|
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
|
||||||
title={t('qslm.hamlogImportTitle')}>
|
title={t('qslm.hamlogImportTitle')}>
|
||||||
@@ -736,6 +783,19 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
|||||||
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||||
{t('qslm.addNotFound')}
|
{t('qslm.addNotFound')}
|
||||||
</label>
|
</label>
|
||||||
|
{service === 'lotw' && (
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title={t('qslm.lotwDetailTitle')}>
|
||||||
|
<Checkbox checked={lotwDetail || addNotFound} disabled={addNotFound}
|
||||||
|
onCheckedChange={(c) => { setLotwDetail(!!c); SetLoTWQSLDetail(!!c); }} />
|
||||||
|
{t('qslm.lotwDetail')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{service === 'lotw' && (
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title={t('qslm.lotwAllCallsTitle')}>
|
||||||
|
<Checkbox checked={lotwAllCalls} onCheckedChange={(c) => { setLotwAllCalls(!!c); SetLoTWDownloadAllCalls(!!c); }} />
|
||||||
|
{t('qslm.lotwAllCalls')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
|||||||
{ service: 'hrdlog', name: 'HRDLog.net' },
|
{ service: 'hrdlog', name: 'HRDLog.net' },
|
||||||
{ service: 'eqsl', name: 'eQSL.cc' },
|
{ service: 'eqsl', name: 'eQSL.cc' },
|
||||||
{ service: 'lotw', name: 'LoTW' },
|
{ service: 'lotw', name: 'LoTW' },
|
||||||
{ service: 'hamlog', name: 'HAMLOG.online' },
|
{ service: 'hamqth', name: 'HamQTH' },
|
||||||
|
{ service: 'cloudlog', name: 'Cloudlog / Wavelog' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Combobox } from '@/components/ui/combobox';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { flagURL } from '@/lib/flags';
|
import { flagURL } from '@/lib/flags';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { bandForMHz } from '@/lib/bandplan';
|
||||||
import { titleCase, sentenceCase } from '@/lib/textCase';
|
import { titleCase, sentenceCase } from '@/lib/textCase';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ const CONFIRMATIONS: ConfDef[] = [
|
|||||||
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
|
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
|
||||||
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
|
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
|
||||||
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
|
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
|
||||||
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
|
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any, rcvd: 'clublog_qso_download_status' as any, rcvdDate: 'clublog_qso_download_date' as any },
|
||||||
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
|
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
|
||||||
];
|
];
|
||||||
// i18n label keys for confirmation channels whose label has translatable words
|
// i18n label keys for confirmation channels whose label has translatable words
|
||||||
@@ -80,6 +81,19 @@ const CONF_LABEL_KEYS: Record<string, string> = {
|
|||||||
QSL: 'qedit.confQslPaper',
|
QSL: 'qedit.confQslPaper',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Reading order for the channel list: the two that carry an ARRL award first —
|
||||||
|
// paper QSL and LoTW — then everything else alphabetically, so a channel is
|
||||||
|
// found by its name rather than by remembering the order it was added in.
|
||||||
|
const CONF_FIRST: Record<string, number> = { QSL: 0, LOTW: 1 };
|
||||||
|
function confOrder<T extends { key: string; label: string }>(rows: T[]): T[] {
|
||||||
|
return rows.slice().sort((a, b) => {
|
||||||
|
const ra = CONF_FIRST[a.key] ?? 2;
|
||||||
|
const rb = CONF_FIRST[b.key] ?? 2;
|
||||||
|
if (ra !== rb) return ra - rb;
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
|
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
|
||||||
// columns and this channel is backed by ADIF extras — but it still belongs in
|
// columns and this channel is backed by ADIF extras — but it still belongs in
|
||||||
// the channel picker and the status table alongside the rest.
|
// the channel picker and the status table alongside the rest.
|
||||||
@@ -98,6 +112,15 @@ const HAMLOG_KEYS = {
|
|||||||
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE',
|
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// HamQTH — extras again (ADIF names no HamQTH field), and SENT only: the site
|
||||||
|
// publishes no confirmation feed, so a received column here would be a promise
|
||||||
|
// nothing can keep.
|
||||||
|
const HAMQTH_CONF = 'HAMQTH';
|
||||||
|
const HAMQTH_KEYS = {
|
||||||
|
sent: 'APP_OPSLOG_HAMQTH_SENT',
|
||||||
|
sentDate: 'APP_OPSLOG_HAMQTH_SENT_DATE',
|
||||||
|
};
|
||||||
|
|
||||||
// Colour-coded status cell for the confirmation grid.
|
// Colour-coded status cell for the confirmation grid.
|
||||||
function StatusCell({ value }: { value?: string }) {
|
function StatusCell({ value }: { value?: string }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -290,6 +313,19 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
const splitHz = (hz?: number) => hz
|
const splitHz = (hz?: number) => hz
|
||||||
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
||||||
: { khz: '', hz: '' };
|
: { khz: '', hz: '' };
|
||||||
|
// Correcting a frequency corrects its band. The pair has to agree — the log,
|
||||||
|
// every award and every upload are read on the BAND — and an operator fixing
|
||||||
|
// a wrong frequency is not also expecting to fix the band by hand, which is
|
||||||
|
// exactly how a QSO ends up filed on 20m at 7 MHz.
|
||||||
|
//
|
||||||
|
// Only when the number lands in a known allocation: half a frequency is typed
|
||||||
|
// on the way to all of it, and a band must never be blanked by that.
|
||||||
|
const syncBand = (khz: string, hz: string, field: 'band' | 'band_rx') => {
|
||||||
|
if (!khz.trim()) return;
|
||||||
|
const b = bandForMHz((parseInt(khz, 10) * 1000 + (parseInt(hz, 10) || 0)) / 1_000_000);
|
||||||
|
if (b) set(field, b as any);
|
||||||
|
};
|
||||||
|
|
||||||
const f0 = splitHz(draft.freq_hz);
|
const f0 = splitHz(draft.freq_hz);
|
||||||
const fr0 = splitHz(draft.freq_rx_hz);
|
const fr0 = splitHz(draft.freq_rx_hz);
|
||||||
const [freqKHz, setFreqKHz] = useState(f0.khz);
|
const [freqKHz, setFreqKHz] = useState(f0.khz);
|
||||||
@@ -612,13 +648,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
|
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
|
||||||
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
<Input value={freqKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqKHz(v); syncBand(v, freqHz, 'band'); }} className="font-mono w-24" placeholder="kHz" />
|
||||||
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
<Input value={freqHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqHz(v); syncBand(freqKHz, v, 'band'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
|
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
|
||||||
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
<Input value={freqRxKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxKHz(v); syncBand(v, freqRxHz, 'band_rx'); }} className="font-mono w-24" placeholder="kHz" />
|
||||||
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
<Input value={freqRxHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxHz(v); syncBand(freqRxKHz, v, 'band_rx'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -747,19 +783,34 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<Select value={confSel} onValueChange={setConfSel}>
|
<Select value={confSel} onValueChange={setConfSel}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}
|
{confOrder([
|
||||||
{/* Listed here but NOT in CONFIRMATIONS: that table maps
|
...CONFIRMATIONS.map((c) => ({ key: c.key, label: CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label })),
|
||||||
|
{ key: OPSLOG_CONF, label: t('qedit.confOpsLog') },
|
||||||
|
{ key: HAMLOG_CONF, label: 'HAMLOG.online' },
|
||||||
|
{ key: HAMQTH_CONF, label: 'HamQTH' },
|
||||||
|
]).map((c) => <SelectItem key={c.key} value={c.key}>{c.label}</SelectItem>)}
|
||||||
|
{/* The three above that are NOT in CONFIRMATIONS — the
|
||||||
QSO columns, and this channel lives in the ADIF
|
QSO columns, and this channel lives in the ADIF
|
||||||
extras. It gets its own editor below rather than the
|
OpsLog card, HAMLOG.online and HamQTH — are backed by
|
||||||
generic sent/received/date grid, which has no field
|
ADIF extras rather than QSO columns, and each gets its
|
||||||
to bind to. */}
|
own editor below instead of the generic
|
||||||
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem>
|
sent/received/date grid, which has no field to bind
|
||||||
<SelectItem value={HAMLOG_CONF}>HAMLOG.online</SelectItem>
|
to. */}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{confSel === HAMLOG_CONF ? (
|
{confSel === HAMQTH_CONF ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMQTH_KEYS.sent)} onChange={(v) => exPut(HAMQTH_KEYS.sent, v)} /></div>
|
||||||
|
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={exVal(HAMQTH_KEYS.sentDate)} onChange={(v) => exPut(HAMQTH_KEYS.sentDate, v)} /></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : confSel === HAMLOG_CONF ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
|
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
|
||||||
@@ -845,30 +896,32 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{CONFIRMATIONS.map((c) => (
|
{/* The extras-backed channels (OpsLog card,
|
||||||
|
HAMLOG.online, HamQTH) sit in the same ordered list
|
||||||
|
as the column-backed ones: the reader is looking for
|
||||||
|
a name, not for a storage detail. A dash in RECEIVED
|
||||||
|
means the channel has nothing to receive — Club Log
|
||||||
|
and HamQTH publish no confirmations — which is not
|
||||||
|
the same statement as "N". */}
|
||||||
|
{confOrder([
|
||||||
|
...CONFIRMATIONS.map((c) => ({
|
||||||
|
key: c.key,
|
||||||
|
label: CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label,
|
||||||
|
sent: val(c.sent),
|
||||||
|
rcvd: c.rcvd ? val(c.rcvd) : null,
|
||||||
|
})),
|
||||||
|
{ key: OPSLOG_CONF, label: t('qedit.confOpsLog'), sent: opslogQslSent ? 'Y' : 'N', rcvd: qslReceived ? 'Y' : 'N' },
|
||||||
|
{ key: HAMLOG_CONF, label: 'HAMLOG.online', sent: exVal(HAMLOG_KEYS.sent), rcvd: exVal(HAMLOG_KEYS.rcvd) },
|
||||||
|
{ key: HAMQTH_CONF, label: 'HamQTH', sent: exVal(HAMQTH_KEYS.sent), rcvd: null },
|
||||||
|
]).map((c) => (
|
||||||
<tr key={c.key} className="text-xs">
|
<tr key={c.key} className="text-xs">
|
||||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
|
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{c.label}</td>
|
||||||
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
<td className="w-24"><StatusCell value={c.sent} /></td>
|
||||||
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
<td className="w-24">{c.rcvd === null
|
||||||
|
? <span className="block text-center text-[11px] text-muted-foreground">—</span>
|
||||||
|
: <StatusCell value={c.rcvd} />}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{/* OpsLog's own card, read from the ADIF extras rather
|
|
||||||
than a QSO column — hence a hand-written row instead
|
|
||||||
of a CONFIRMATIONS entry. "Sent" is stamped by OpsLog
|
|
||||||
when the card actually goes out, so it stays
|
|
||||||
read-only here: an operator ticking it by hand would
|
|
||||||
be recording something that never happened. */}
|
|
||||||
<tr className="text-xs">
|
|
||||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{t('qedit.confOpsLog')}</td>
|
|
||||||
<td className="w-24"><StatusCell value={opslogQslSent ? 'Y' : 'N'} /></td>
|
|
||||||
<td className="w-24"><StatusCell value={qslReceived ? 'Y' : 'N'} /></td>
|
|
||||||
</tr>
|
|
||||||
{/* HAMLOG.online — extras again, same hand-written row. */}
|
|
||||||
<tr className="text-xs">
|
|
||||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">HAMLOG.online</td>
|
|
||||||
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.sent)} /></td>
|
|
||||||
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.rcvd)} /></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat }
|
|||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
|
import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units';
|
||||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
@@ -177,8 +178,9 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
|||||||
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||||
// Derived, not stored: computed from the two locations at display time, like
|
// Derived, not stored: computed from the two locations at display time, like
|
||||||
// the cluster grid's own distance column.
|
// the cluster grid's own distance column.
|
||||||
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km', headerName: t('rqg.h.distance_km'), width: 90, type: 'rightAligned', cellClass: 'font-mono',
|
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km',
|
||||||
valueGetter: (p) => qsoDistanceKm(p.data, myGrid),
|
headerName: t('rqg.h.distance_km') + ' (' + distanceUnit() + ')', width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
|
valueGetter: (p) => { const km = qsoDistanceKm(p.data, myGrid); return km ? distanceValue(km) : km; },
|
||||||
comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||||
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
|
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
|
||||||
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||||
@@ -217,6 +219,9 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
|||||||
{ group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
|
{ group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
|
||||||
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
|
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
|
||||||
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
|
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
|
||||||
|
// HamQTH, the extras again — sent only, the site having no confirmations.
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamqth_sent'), colId: 'hamqth_sent', headerName: t('rqg.h.hamqth_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMQTH_SENT'] || 'N'; }, defaultVisible: false },
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamqth_sent_date'), colId: 'hamqth_sent_date', headerName: t('rqg.h.hamqth_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMQTH_SENT_DATE']), defaultVisible: false },
|
||||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||||
|
|
||||||
@@ -233,6 +238,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
|||||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd'), colId: 'clublog_qso_download_status', headerName: t('rqg.c.clublog_rcvd'), field: 'clublog_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd_date'), colId: 'clublog_qso_download_date', headerName: t('rqg.h.clublog_rcvd_date'), field: 'clublog_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
|
||||||
// ── Contest ──
|
// ── Contest ──
|
||||||
{ group: 'Contest', label: t('rqg.c.contest_id'), colId: 'contest_id', headerName: t('rqg.h.contest_id'), field: 'contest_id' as any, width: 110 },
|
{ group: 'Contest', label: t('rqg.c.contest_id'), colId: 'contest_id', headerName: t('rqg.h.contest_id'), field: 'contest_id' as any, width: 110 },
|
||||||
@@ -335,7 +342,9 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
// inside the column definitions, so nothing else would notice.
|
// inside the column definitions, so nothing else would notice.
|
||||||
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||||
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
const [distUnit, setDistUnit] = useState(distanceUnit);
|
||||||
|
useEffect(() => subscribeDistanceUnit(() => setDistUnit(distanceUnit())), []);
|
||||||
|
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt, distUnit]);
|
||||||
|
|
||||||
// Right-click: if the clicked row isn't already part of the selection,
|
// Right-click: if the clicked row isn't already part of the selection,
|
||||||
// select just it; then open the bulk-action menu on the whole selection.
|
// select just it; then open the bulk-action menu on the whole selection.
|
||||||
@@ -780,7 +789,11 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||||
{GROUP_ORDER.map((group) => {
|
{GROUP_ORDER.map((group) => {
|
||||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
// Alphabetical within the group: the catalog's order is the
|
||||||
|
// GRID's column order, which appends newcomers at the end — so
|
||||||
|
// the two ClubLog rows sat at opposite ends of the Uploads box.
|
||||||
|
const cols = COL_CATALOG.filter((c) => c.group === group)
|
||||||
|
.slice().sort((a, b) => (a.label ?? '').localeCompare(b.label ?? ''));
|
||||||
if (cols.length === 0) return null;
|
if (cols.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={group} className="rounded-md border border-border p-2.5">
|
<div key={group} className="rounded-md border border-border p-2.5">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,381 @@
|
|||||||
|
// RotorCompassClassic — the original rotor dial, kept as an option.
|
||||||
|
//
|
||||||
|
// Settings → Rotator chooses between this and the current compass. It was
|
||||||
|
// replaced rather than removed because the two answer the same question in
|
||||||
|
// different ways: this one is a small light-map dial with the quick turns in a
|
||||||
|
// column beside it, and an operator used to it should not have to relearn a
|
||||||
|
// panel to keep working.
|
||||||
|
//
|
||||||
|
// An azimuthal-equidistant rotor display (à la 4O3A RotorGenius).
|
||||||
|
//
|
||||||
|
// A world map centred on the operator's QTH (north up) fills the dial, ringed by
|
||||||
|
// a green azimuth bezel. A green needle shows the antenna heading (two needles
|
||||||
|
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
|
||||||
|
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
|
||||||
|
// to turn the antenna there.
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
|
||||||
|
import { feature } from 'topojson-client';
|
||||||
|
import landTopo from 'world-atlas/land-110m.json';
|
||||||
|
import { Compass, X, Play, Square } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
// Decode the coastline outline once (≈110 m simplified land polygons).
|
||||||
|
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
|
||||||
|
const GRATICULE = geoGraticule10();
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bearing?: number | null; // short-path azimuth to DX (deg)
|
||||||
|
headings: number[]; // radiating heading(s) — rotor + Ultrabeam pattern
|
||||||
|
boomHeading?: number | null; // mechanical boom (rotor) azimuth, shown grey when it differs
|
||||||
|
pattern?: 'normal' | 'reverse' | 'bi' | null; // Ultrabeam pattern (for the badge)
|
||||||
|
centerLat?: number | null; // operator latitude (projection centre)
|
||||||
|
centerLon?: number | null; // operator longitude
|
||||||
|
rotorEnabled?: boolean;
|
||||||
|
rotors?: string[]; // logical rotor names; >1 → show a selector
|
||||||
|
activeRotor?: number; // index of the selected rotor (0-based)
|
||||||
|
onSelectRotor?: (i: number) => void; // switch the active rotor
|
||||||
|
onGoto?: (az: number) => void; // click-to-turn
|
||||||
|
onClose?: () => void;
|
||||||
|
// Quick-turn buttons and the azimuth box, shown only where the caller wants
|
||||||
|
// them: Station Control draws its own GoTo/Stop around this compass, and two
|
||||||
|
// sets of the same controls side by side would be nothing but confusing.
|
||||||
|
presets?: { label: string; azimuth: number }[];
|
||||||
|
onStop?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE = 168;
|
||||||
|
const C = SIZE / 2;
|
||||||
|
const R = C - 6; // outer bezel radius
|
||||||
|
const MAP_R = R - 6; // map/clip radius (inside the bezel)
|
||||||
|
|
||||||
|
function pt(az: number, radius: number): [number, number] {
|
||||||
|
const a = ((az - 90) * Math.PI) / 180;
|
||||||
|
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RotorCompassClassic({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose, presets, onStop }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
// Raw text, not a number: binding the input to a normalised value makes
|
||||||
|
// Backspace fight the operator on the way from "230" to "23". It is parsed
|
||||||
|
// when it is sent, and only then.
|
||||||
|
const [azText, setAzText] = useState('');
|
||||||
|
const showControls = !!(presets || onStop);
|
||||||
|
|
||||||
|
// Which preset was just pressed, so it can light up for a moment.
|
||||||
|
//
|
||||||
|
// A rotor takes seconds to start moving and the needle barely twitches at
|
||||||
|
// first, so without this the only answer to "did that register?" is to press
|
||||||
|
// it again — which is how an antenna ends up ordered somewhere twice. The
|
||||||
|
// acknowledgement has to come from the button itself, at once.
|
||||||
|
const [flashIdx, setFlashIdx] = useState<number | null>(null);
|
||||||
|
const flashTimer = useRef<number | undefined>(undefined);
|
||||||
|
useEffect(() => () => window.clearTimeout(flashTimer.current), []);
|
||||||
|
const pressPreset = (i: number, az: number) => {
|
||||||
|
if (!onGoto) return;
|
||||||
|
setFlashIdx(i);
|
||||||
|
window.clearTimeout(flashTimer.current);
|
||||||
|
flashTimer.current = window.setTimeout(() => setFlashIdx(null), 450);
|
||||||
|
onGoto(az);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stop needs the same acknowledgement, for the same reason and one more: it
|
||||||
|
// is pressed when something is already wrong, and a button that stays inert
|
||||||
|
// gets hit again and again. Its own flag, so stopping does not blank a preset
|
||||||
|
// that is still lit.
|
||||||
|
const [stopFlash, setStopFlash] = useState(false);
|
||||||
|
const stopTimer = useRef<number | undefined>(undefined);
|
||||||
|
useEffect(() => () => window.clearTimeout(stopTimer.current), []);
|
||||||
|
const pressStop = () => {
|
||||||
|
if (!onStop) return;
|
||||||
|
setStopFlash(true);
|
||||||
|
window.clearTimeout(stopTimer.current);
|
||||||
|
stopTimer.current = window.setTimeout(() => setStopFlash(false), 450);
|
||||||
|
onStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 0-359 and nothing else. 360 is refused rather than folded to 0 — it is
|
||||||
|
// almost always a typo for 36 or 306, and a rotor swinging through north on a
|
||||||
|
// slip of the finger is worth one rejected keypress.
|
||||||
|
const sendAz = () => {
|
||||||
|
const s = azText.trim();
|
||||||
|
if (s === '' || !onGoto) return;
|
||||||
|
const n = Number(s);
|
||||||
|
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 359) return;
|
||||||
|
onGoto(n);
|
||||||
|
setAzText('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardinals = useMemo(
|
||||||
|
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
||||||
|
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Project the world centred on the QTH (north up; antipode at the bezel).
|
||||||
|
const { land, grat } = useMemo(() => {
|
||||||
|
if (centerLat == null || centerLon == null) return { land: '', grat: '' };
|
||||||
|
const proj = geoAzimuthalEquidistant()
|
||||||
|
.rotate([-centerLon, -centerLat])
|
||||||
|
.clipAngle(179.9)
|
||||||
|
.scale(MAP_R / Math.PI)
|
||||||
|
.translate([C, C]);
|
||||||
|
const path = geoPath(proj as any);
|
||||||
|
return { land: path(LAND as any) || '', grat: path(GRATICULE as any) || '' };
|
||||||
|
}, [centerLat, centerLon]);
|
||||||
|
|
||||||
|
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
|
||||||
|
if (!onGoto) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * SIZE - C;
|
||||||
|
const y = ((e.clientY - rect.top) / rect.height) * SIZE - C;
|
||||||
|
let az = (Math.atan2(y, x) * 180) / Math.PI + 90;
|
||||||
|
az = ((az % 360) + 360) % 360;
|
||||||
|
onGoto(Math.round(az));
|
||||||
|
}
|
||||||
|
|
||||||
|
const headLabel = headings.length ? headings[0] : null;
|
||||||
|
|
||||||
|
// Short and long path to the DX, in figures.
|
||||||
|
//
|
||||||
|
// The bezel already carries the short path as a red marker, but a marker is a
|
||||||
|
// direction, not a number — the same pair sits in the status bar at 10px and
|
||||||
|
// operators reported not being able to read it. It lives here because it
|
||||||
|
// belongs to the compass: every place that draws one gets the readout, instead
|
||||||
|
// of each caller inventing its own. Clickable when the caller can turn, like
|
||||||
|
// the status bar's. Built as a value because it is placed in one of two
|
||||||
|
// columns depending on whether the controls are shown.
|
||||||
|
const pathReadout = (
|
||||||
|
<div className="flex gap-1.5 mt-2 font-mono w-full">
|
||||||
|
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
|
||||||
|
<button key={lbl} type="button" disabled={az == null || !onGoto}
|
||||||
|
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
|
||||||
|
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
|
||||||
|
className={
|
||||||
|
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors active:scale-95 ' +
|
||||||
|
(az == null
|
||||||
|
? 'border-border text-muted-foreground/50 cursor-not-allowed'
|
||||||
|
: onGoto
|
||||||
|
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
|
||||||
|
: 'border-border text-muted-foreground cursor-default')
|
||||||
|
}>
|
||||||
|
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
{/* Header — matches the WinKeyer / Voice keyer panels. */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
||||||
|
<Compass className="size-4 text-primary shrink-0" />
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
|
||||||
|
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
|
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
|
||||||
|
<div className="flex-1" />
|
||||||
|
{pattern && (
|
||||||
|
<span
|
||||||
|
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
|
||||||
|
pattern === 'reverse' ? 'bg-warning-muted text-warning-muted-foreground'
|
||||||
|
: pattern === 'bi' ? 'bg-info-muted text-info-muted-foreground'
|
||||||
|
: 'bg-success-muted text-success-muted-foreground')}
|
||||||
|
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
|
||||||
|
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
|
||||||
|
: 'Ultrabeam normal'}>
|
||||||
|
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="font-mono text-sm font-bold text-success tabular-nums">
|
||||||
|
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
||||||
|
</span>
|
||||||
|
{onClose && (
|
||||||
|
<button className="text-muted-foreground hover:text-foreground" title="Hide rotor" onClick={onClose}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Multiple rotors — pick which one the dial shows and turns. */}
|
||||||
|
{rotors && rotors.length > 1 && (
|
||||||
|
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
|
||||||
|
{rotors.map((nm, i) => {
|
||||||
|
const active = (activeRotor ?? 0) === i;
|
||||||
|
const label = nm?.trim() || `Rotor ${i + 1}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onSelectRotor?.(i)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 min-w-[48px] px-1.5 py-0.5 rounded text-[10px] font-semibold truncate transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-success text-success-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground hover:bg-muted/70',
|
||||||
|
)}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dial on the left, controls on the right. The controls column is sized to
|
||||||
|
fit WITHIN the dial's height: the widget sits in a row whose height is
|
||||||
|
set by the entry strip, so it may grow sideways but never downwards. */}
|
||||||
|
<div className="flex items-start gap-2 p-2 min-h-0">
|
||||||
|
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a
|
||||||
|
sibling of the <svg> landed beside it. */}
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-0 shrink-0">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
|
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
|
||||||
|
style={{ width: SIZE, height: SIZE }}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="rotorDial"><circle cx={C} cy={C} r={MAP_R} /></clipPath>
|
||||||
|
</defs>
|
||||||
|
{/* water + world map, clipped to the dial */}
|
||||||
|
<circle cx={C} cy={C} r={MAP_R} fill="#d3e7f1" />
|
||||||
|
<g clipPath="url(#rotorDial)">
|
||||||
|
{grat && <path d={grat} fill="none" stroke="#9cc0d6" strokeWidth={0.4} opacity={0.7} />}
|
||||||
|
{land && <path d={land} fill="#dfe2cf" stroke="#9aa589" strokeWidth={0.4} />}
|
||||||
|
</g>
|
||||||
|
{/* green azimuth bezel */}
|
||||||
|
<circle cx={C} cy={C} r={R} fill="none" stroke="#16a34a" strokeWidth={5} />
|
||||||
|
|
||||||
|
{/* ticks every 10°, longer at 30° */}
|
||||||
|
{Array.from({ length: 36 }, (_, i) => i * 10).map((d) => {
|
||||||
|
const major = d % 30 === 0;
|
||||||
|
const [x1, y1] = pt(d, MAP_R);
|
||||||
|
const [x2, y2] = pt(d, MAP_R - (major ? 7 : 4));
|
||||||
|
return <line key={d} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#475569" strokeWidth={major ? 1 : 0.6} opacity={0.7} />;
|
||||||
|
})}
|
||||||
|
{/* cardinal labels + degree numbers at 45° */}
|
||||||
|
{cardinals.map(({ d, l }) => {
|
||||||
|
const [x, y] = pt(d, MAP_R - 13);
|
||||||
|
return <text key={l} x={x} y={y} textAnchor="middle" dominantBaseline="central" className="fill-slate-700" style={{ fontSize: l.length > 1 ? 7 : 9, fontWeight: 700 }}>{l}</text>;
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* DX short-path bearing → small red marker on the bezel */}
|
||||||
|
{bearing != null && (() => { const [x, y] = pt(bearing, MAP_R); return (
|
||||||
|
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
|
||||||
|
); })()}
|
||||||
|
|
||||||
|
{/* mechanical boom (rotor) heading — grey dashed needle, shown when the
|
||||||
|
Ultrabeam radiates somewhere other than the boom (reverse/bi) so the
|
||||||
|
operator sees where the antenna physically points vs where it boom-sits */}
|
||||||
|
{boomHeading != null && pattern && pattern !== 'normal' && (() => {
|
||||||
|
const [x, y] = pt(boomHeading, MAP_R - 2);
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<title>Boom (rotor) {Math.round(boomHeading)}°</title>
|
||||||
|
<line x1={C} y1={C} x2={x} y2={y} stroke="#64748b" strokeWidth={2} strokeDasharray="3 3" strokeLinecap="round" />
|
||||||
|
<circle cx={x} cy={y} r={3} fill="#64748b" stroke="#fff" strokeWidth={1} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* radiating heading needle(s) — green; two when bidirectional */}
|
||||||
|
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
|
||||||
|
<g key={i}>
|
||||||
|
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
|
||||||
|
<polygon points={`${x},${y} ${pt(h - 5, MAP_R - 12).join(',')} ${pt(h + 5, MAP_R - 12).join(',')}`} fill="#15803d" opacity={i === 0 ? 1 : 0.55} />
|
||||||
|
</g>
|
||||||
|
); })}
|
||||||
|
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* With the controls column present the readout goes at the FOOT OF IT
|
||||||
|
instead: the dial sets the widget's height, the controls are shorter
|
||||||
|
than the dial, and that leftover space is exactly the right size for
|
||||||
|
the pair. Under the dial it would push the whole widget taller. */}
|
||||||
|
{!showControls && pathReadout}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick turns + free azimuth + Stop. */}
|
||||||
|
{showControls && (
|
||||||
|
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||||
|
{/* Two columns so six regions fit beside the dial rather than under
|
||||||
|
it. An operator with fewer keeps the same compact block. */}
|
||||||
|
{!!presets?.length && (
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
{presets.map((p, i) => (
|
||||||
|
<button
|
||||||
|
key={`${p.label}-${i}`}
|
||||||
|
type="button"
|
||||||
|
disabled={!onGoto}
|
||||||
|
onClick={() => pressPreset(i, p.azimuth)}
|
||||||
|
title={`${p.label} — ${p.azimuth}°`}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border py-1 text-xs font-semibold truncate transition-all duration-150 active:scale-95',
|
||||||
|
flashIdx === i
|
||||||
|
// Lit, and showing the azimuth it just sent: the label alone
|
||||||
|
// would only say the press landed, not what was ordered.
|
||||||
|
? 'border-success bg-success text-success-foreground scale-95'
|
||||||
|
: 'border-border bg-muted/40',
|
||||||
|
onGoto && flashIdx !== i ? 'hover:bg-muted' : '',
|
||||||
|
!onGoto ? 'opacity-50 cursor-not-allowed' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{flashIdx === i ? `${p.azimuth}°` : p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Free azimuth: Enter sends, so the whole thing is type-three-digits
|
||||||
|
-and-go without reaching for the mouse. */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={azText}
|
||||||
|
onChange={(e) => setAzText(e.target.value.replace(/[^0-9]/g, '').slice(0, 3))}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendAz(); } }}
|
||||||
|
placeholder={t('rotor.azPh')}
|
||||||
|
title={t('rotor.azTitle')}
|
||||||
|
disabled={!onGoto}
|
||||||
|
className="min-w-0 flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-mono tabular-nums text-center disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={sendAz}
|
||||||
|
disabled={!onGoto || azText.trim() === ''}
|
||||||
|
title={t('rotor.go')}
|
||||||
|
className="flex items-center gap-1 rounded-md border border-success/60 bg-success-muted px-2 py-1 text-xs font-bold text-success-muted-foreground transition-transform hover:bg-success/25 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Play className="size-3" /> {t('rotor.go')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onStop && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={pressStop}
|
||||||
|
title={t('rotor.stop')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center gap-1.5 rounded-md border py-1 text-xs font-bold transition-all duration-150 active:scale-95',
|
||||||
|
stopFlash
|
||||||
|
// Solid, not a tint: STOP reads the same in both languages, so
|
||||||
|
// the fill is the whole acknowledgement.
|
||||||
|
? 'border-destructive bg-destructive text-destructive-foreground scale-95'
|
||||||
|
: 'border-destructive/60 bg-destructive/15 text-destructive hover:bg-destructive/25',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Square className="size-3 fill-current" /> {t('rotor.stop')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* mt-auto: the readout sits at the FOOT of the column, level with the
|
||||||
|
bottom of the dial, instead of floating under the Stop button. */}
|
||||||
|
<div className="mt-auto">{pathReadout}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ShiftRow — the RIT / XIT offset control, shared by the radio consoles.
|
||||||
|
//
|
||||||
|
// It began inside the Icom panel and is here because the next console needed
|
||||||
|
// exactly it. Consoles that each invent their own way of nudging an offset make
|
||||||
|
// an operator learn the same thing twice, which is the complaint that moved it:
|
||||||
|
// "none of the consoles look alike".
|
||||||
|
//
|
||||||
|
// Three ways to move it, because operators reach for different ones: the ± keys,
|
||||||
|
// the wheel over the number, and TYPING a value straight in. The last one is
|
||||||
|
// what a button row cannot do — 'put me 300 Hz down' is one keystroke sequence,
|
||||||
|
// not thirty clicks.
|
||||||
|
export function ShiftRow({ label, on, hz, accent, disabled, step = 10, onToggle, onSet }: {
|
||||||
|
label: string;
|
||||||
|
on: boolean;
|
||||||
|
hz: number;
|
||||||
|
accent: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
step?: number;
|
||||||
|
onToggle: () => void;
|
||||||
|
onSet: (hz: number) => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [editing, setEditing] = useState<string | null>(null);
|
||||||
|
const cb = useRef(onSet); cb.current = onSet;
|
||||||
|
const cur = useRef({ hz, on, disabled }); cur.current = { hz, on, disabled };
|
||||||
|
|
||||||
|
// Wheel over the row. A native non-passive listener, because React's onWheel
|
||||||
|
// is passive and cannot preventDefault — without that the panel scrolls under
|
||||||
|
// the pointer while the number changes.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
const c = cur.current;
|
||||||
|
if (c.disabled || !c.on) return;
|
||||||
|
e.preventDefault();
|
||||||
|
cb.current(c.hz + (e.deltaY < 0 ? step : -step));
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
|
const commit = (raw: string) => {
|
||||||
|
setEditing(null);
|
||||||
|
const v = parseInt(raw.replace(/[^0-9+-]/g, ''), 10);
|
||||||
|
if (!Number.isNaN(v)) onSet(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dead = disabled || !on;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" onClick={onToggle} disabled={disabled}
|
||||||
|
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors disabled:opacity-30',
|
||||||
|
on ? 'bg-success border-success text-success-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
<div ref={ref} title="Wheel, ± or type"
|
||||||
|
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none',
|
||||||
|
dead ? 'border-border/60 bg-muted/20 opacity-60' : 'border-border bg-muted/40 cursor-ns-resize')}>
|
||||||
|
<button type="button" disabled={dead} onClick={() => onSet(hz - step)}
|
||||||
|
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:opacity-40">−</button>
|
||||||
|
{editing !== null ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={editing}
|
||||||
|
onChange={(e) => setEditing(e.target.value)}
|
||||||
|
onBlur={(e) => commit(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') commit((e.target as HTMLInputElement).value);
|
||||||
|
else if (e.key === 'Escape') setEditing(null);
|
||||||
|
}}
|
||||||
|
className="w-20 bg-transparent text-center text-sm font-mono font-bold tabular-nums outline-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button type="button" disabled={dead} onClick={() => setEditing(String(hz))}
|
||||||
|
className="text-sm font-mono font-bold tabular-nums disabled:cursor-default"
|
||||||
|
style={{ color: on ? accent : undefined }}>
|
||||||
|
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz)} Hz
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" disabled={dead} onClick={() => onSet(hz + step)}
|
||||||
|
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:opacity-40">+</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" disabled={dead} onClick={() => onSet(0)}
|
||||||
|
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted disabled:opacity-30">0</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
||||||
import { RotorCompass } from '@/components/RotorCompass';
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
|
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
|
||||||
|
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
|
||||||
import { AmpCard } from '@/components/AmpCard';
|
import { AmpCard } from '@/components/AmpCard';
|
||||||
import { TunerCard } from '@/components/TunerCard';
|
import { TunerCard } from '@/components/TunerCard';
|
||||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||||
@@ -128,6 +130,11 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
|||||||
}) {
|
}) {
|
||||||
const [goto, setGoto] = useState('');
|
const [goto, setGoto] = useState('');
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
|
// Both compasses in the app follow the same preference (Settings → Rotator):
|
||||||
|
// one operator's choice of dial, not one per panel.
|
||||||
|
const [dial, setDial] = useState(() => rotorStyle());
|
||||||
|
useEffect(() => subscribeRotorStyle(() => setDial(rotorStyle())), []);
|
||||||
|
const Dial = dial === 'classic' ? RotorCompassClassic : RotorCompass;
|
||||||
|
|
||||||
const turn = (az: number) => {
|
const turn = (az: number) => {
|
||||||
const a = ((Math.round(az) % 360) + 360) % 360;
|
const a = ((Math.round(az) % 360) + 360) % 360;
|
||||||
@@ -145,9 +152,12 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
|||||||
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 flex gap-4 items-start">
|
<div className="p-3 flex gap-4 items-start">
|
||||||
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the
|
{/* The compact compass is the dial alone — short and long path are the
|
||||||
app carries it rather than each caller drawing its own. */}
|
two coloured dots on it. The figures are in the status bar. */}
|
||||||
<RotorCompass
|
{/* The dial-only form is square and fills the box it is given, so its
|
||||||
|
size is set here rather than baked into the component. */}
|
||||||
|
<div className={dial === 'classic' ? 'shrink-0' : 'w-[210px] shrink-0'}>
|
||||||
|
<Dial
|
||||||
bearing={bearing ?? null}
|
bearing={bearing ?? null}
|
||||||
headings={hd.ok ? [hd.azimuth] : []}
|
headings={hd.ok ? [hd.azimuth] : []}
|
||||||
centerLat={centerLat ?? null}
|
centerLat={centerLat ?? null}
|
||||||
@@ -158,6 +168,7 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
|||||||
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
||||||
onGoto={(az) => turn(az)}
|
onGoto={(az) => turn(az)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex-1 min-w-0 space-y-2">
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
<div className="font-mono">
|
<div className="font-mono">
|
||||||
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Radio, Activity, AudioLines, SlidersHorizontal, Mic } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetTCIPanel, GetCATState,
|
||||||
|
SetTCIDrive, SetTCITuneDrive, SetTCIMicLevel, SetTCIVolume, SetTCIMute,
|
||||||
|
SetTCIAGC, SetTCISquelch, SetTCISquelchLevel,
|
||||||
|
SetTCINB, SetTCINR, SetTCIANF, SetTCIAPF, SetTCIFilter,
|
||||||
|
SetTCIRIT, SetTCIXIT, SetTCIRITOffset, SetTCIXITOffset, SetTCILock, SetTCITune,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { sMeterRST } from '@/lib/rst';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
|
import { WheelRange } from '@/components/WheelRange';
|
||||||
|
import { ShiftRow } from '@/components/ShiftRow';
|
||||||
|
import { LevelRow } from '@/components/LevelRow';
|
||||||
|
|
||||||
|
type TCIState = {
|
||||||
|
connected: boolean; device?: string; protocol?: string;
|
||||||
|
drive: number; tune_drive: number; mic_level: number; tx_enabled: boolean; tx: boolean; tuning: boolean;
|
||||||
|
volume: number; mute: boolean; agc?: string; squelch_on: boolean; squelch: number;
|
||||||
|
nb: boolean; nr: boolean; anf: boolean; apf: boolean;
|
||||||
|
filter_lo: number; filter_hi: number;
|
||||||
|
rit: boolean; rit_offset: number; xit: boolean; xit_offset: number; lock: boolean; split: boolean;
|
||||||
|
smeter: number; modulations?: string[];
|
||||||
|
tx_power_w: number; tx_swr: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZERO: TCIState = {
|
||||||
|
connected: false, drive: 0, tune_drive: 0, mic_level: 0, tx_enabled: false, tx: false, tuning: false,
|
||||||
|
volume: 0, mute: false, squelch_on: false, squelch: 0,
|
||||||
|
nb: false, nr: false, anf: false, apf: false, filter_lo: 0, filter_hi: 0,
|
||||||
|
rit: false, rit_offset: 0, xit: false, xit_offset: 0, lock: false, split: false, smeter: 0,
|
||||||
|
tx_power_w: 0, tx_swr: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The widths worth a button, PER MODE — because 250 Hz is useless in SSB and
|
||||||
|
// 2.8 kHz is useless in CW, and a row offering both is a row where half the
|
||||||
|
// buttons are never pressed.
|
||||||
|
//
|
||||||
|
// CW gets the narrow end, where the difference between 250 and 500 is the
|
||||||
|
// difference between one signal and three. Voice gets the range a passband is
|
||||||
|
// actually shaped over. Digital sits between: wide enough for a whole FT8
|
||||||
|
// sub-band, narrow enough for RTTY.
|
||||||
|
const WIDTHS_CW = [100, 250, 400, 500, 700, 1000, 1800];
|
||||||
|
const WIDTHS_SSB = [1800, 2100, 2400, 2700, 2800, 3000, 3500];
|
||||||
|
const WIDTHS_DIGI = [500, 1000, 1800, 2400, 2800, 3000, 3500];
|
||||||
|
|
||||||
|
// widthsFor picks the row from the mode the radio reports.
|
||||||
|
function widthsFor(mode: string): number[] {
|
||||||
|
if (/CW/i.test(mode)) return WIDTHS_CW;
|
||||||
|
if (/SSB|USB|LSB|AM|FM/i.test(mode)) return WIDTHS_SSB;
|
||||||
|
return WIDTHS_DIGI;
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCW says whether the CW-only controls belong on screen at all.
|
||||||
|
function isCW(mode: string): boolean { return /CW/i.test(mode); }
|
||||||
|
|
||||||
|
function widthLabel(w: number): string {
|
||||||
|
return w >= 1000 ? `${(w / 1000).toFixed(1)}k` : String(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
// edgesFor turns a width into the pair TCI wants: 0 to the width, and nothing
|
||||||
|
// clever.
|
||||||
|
//
|
||||||
|
// It centred narrow filters on the CW note first — 250 became 575-825 — on the
|
||||||
|
// reasoning that a CW filter should contain the note. That reasoning may even be
|
||||||
|
// right for a radio, but it is not what the button says, and a button that does
|
||||||
|
// not do what it says is worse than one that does something simple. 250 means
|
||||||
|
// 0-250. The two edges are editable underneath for anything else.
|
||||||
|
function edgesFor(w: number, _mode: string): { lo: number; hi: number } {
|
||||||
|
return { lo: 0, hi: w };
|
||||||
|
}
|
||||||
|
|
||||||
|
// dBm → S units. TCI reports a real signal level rather than a meter position,
|
||||||
|
// which is the useful way round: S9 is -73 dBm by the IARU definition and every
|
||||||
|
// S unit below it is 6 dB, so this is arithmetic rather than a calibration
|
||||||
|
// table — nothing here is provisional the way the K3's meter reading is.
|
||||||
|
function sParts(dbm: number): { s: number; over: number; label: string } {
|
||||||
|
if (dbm === 0) return { s: 0, over: 0, label: '—' };
|
||||||
|
if (dbm >= -73) {
|
||||||
|
const over = Math.round((dbm + 73) / 10) * 10;
|
||||||
|
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||||||
|
}
|
||||||
|
const s = Math.max(0, Math.min(9, Math.round(9 + (dbm + 73) / 6)));
|
||||||
|
return { s, over: 0, label: `S${s}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The meter bar wants 0-100; -127 dBm is the bottom of the scale and -13 dBm
|
||||||
|
// (S9+60) the top.
|
||||||
|
function sBar(dbm: number): number {
|
||||||
|
if (dbm === 0) return 0;
|
||||||
|
return Math.max(0, Math.min(100, ((dbm + 127) / 114) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sSegColor(frac: number): string {
|
||||||
|
return frac > 0.75 ? '#dc2626' : frac > 0.55 ? '#f59e0b' : '#16a34a';
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ icon: Icon, title, children }: { icon: any; title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Icon className="size-4 text-primary" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One button shape for every on/off control, as on the other consoles.
|
||||||
|
function Toggle({ label, on, off, onClick, title }: {
|
||||||
|
label: string; on: boolean; off: boolean; onClick: () => void; title?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button type="button" disabled={off} onClick={onClick} title={title}
|
||||||
|
className={cn('rounded-lg border-2 px-2 py-1.5 text-xs font-bold transition-all disabled:opacity-30',
|
||||||
|
on ? 'bg-primary text-primary-foreground border-primary shadow-[0_0_10px] shadow-primary/40'
|
||||||
|
: 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value, children }: { label: string; value: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-xs font-mono tabular-nums">{value}</span>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [st, setSt] = useState<TCIState>(ZERO);
|
||||||
|
const [freqHz, setFreqHz] = useState(0);
|
||||||
|
const [mode, setMode] = useState('');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
// OPTIMISTIC, like the Icom console — and for a reason found on a real radio.
|
||||||
|
//
|
||||||
|
// This panel used to show only what the radio reported back, on the principle
|
||||||
|
// that the radio is the truth. But ExpertSDR3 does not echo every setting it
|
||||||
|
// is given: press MED and the radio changes, says nothing, and the button
|
||||||
|
// stays lit on SLOW. Waiting for an answer that never comes reads as a dead
|
||||||
|
// control.
|
||||||
|
//
|
||||||
|
// So a change is shown at once and held for a moment. Whatever the radio
|
||||||
|
// announces afterwards — the new value, or a refusal that leaves the old one
|
||||||
|
// — wins once the hold expires, which keeps a clamped or rejected setting
|
||||||
|
// honest without making every working one look broken.
|
||||||
|
// Held UNTIL THE RADIO SPEAKS, not for a fixed moment.
|
||||||
|
//
|
||||||
|
// A timeout was wrong in both directions. Too short and a setting the radio
|
||||||
|
// never echoes — AGC is one — snapped back to its old value a second after
|
||||||
|
// the click. Too long and a setting the radio REFUSES looked accepted: a real
|
||||||
|
// log shows this one answering 'sql_enable:0,false' and then, eighty
|
||||||
|
// milliseconds later, 'sql_enable:0,true' — it puts the squelch straight back
|
||||||
|
// on. Holding through that would have shown the operator a lie.
|
||||||
|
//
|
||||||
|
// So the requested value stands while the radio says nothing about it, and
|
||||||
|
// the instant it reports ANY change for that setting, its word replaces ours.
|
||||||
|
const holdRef = useRef<Record<string, { v: any; reported: any }>>({});
|
||||||
|
const [, forceRender] = useState(0);
|
||||||
|
const hold = <T,>(key: string, reported: T): T => {
|
||||||
|
const h = holdRef.current[key];
|
||||||
|
if (!h) return reported;
|
||||||
|
// The radio has said something different from what it was saying when the
|
||||||
|
// click happened — whether that is our value or a refusal, it is now the
|
||||||
|
// truth and the hold is over.
|
||||||
|
if (reported !== h.reported) {
|
||||||
|
delete holdRef.current[key];
|
||||||
|
return reported;
|
||||||
|
}
|
||||||
|
return h.v as T;
|
||||||
|
};
|
||||||
|
const setHold = (key: string, v: any, reported: any) => {
|
||||||
|
holdRef.current[key] = { v, reported };
|
||||||
|
forceRender((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const p: any = await GetTCIPanel();
|
||||||
|
if (!alive) return;
|
||||||
|
setSt(p as TCIState);
|
||||||
|
const cs: any = await GetCATState();
|
||||||
|
if (!alive) return;
|
||||||
|
setFreqHz(Number(cs?.rx_freq_hz) || Number(cs?.freq_hz) || 0);
|
||||||
|
setMode(String(cs?.mode || ''));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (alive) setErr(String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 400);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const off = !st.connected;
|
||||||
|
|
||||||
|
const call = (fn: () => Promise<any>) => { fn().catch((e: any) => setErr(String(e?.message ?? e))); };
|
||||||
|
|
||||||
|
const drive = hold('drive', st.drive);
|
||||||
|
const tuneDrive = hold('tune_drive', st.tune_drive);
|
||||||
|
const mic = hold('mic', st.mic_level);
|
||||||
|
const vol = hold('vol', st.volume);
|
||||||
|
const sql = hold('sql', st.squelch);
|
||||||
|
const agc = hold('agc', st.agc || '');
|
||||||
|
const nb = hold('nb', st.nb), nr = hold('nr', st.nr);
|
||||||
|
const anf = hold('anf', st.anf), apf = hold('apf', st.apf);
|
||||||
|
const sqlOn = hold('sql_on', st.squelch_on), muted = hold('mute', st.mute);
|
||||||
|
const rit = hold('rit', st.rit), xit = hold('xit', st.xit);
|
||||||
|
const ritHz = hold('rit_hz', st.rit_offset), xitHz = hold('xit_hz', st.xit_offset);
|
||||||
|
const s = sParts(st.smeter);
|
||||||
|
|
||||||
|
// Ctrl+Left/Right shifts the RIT by ±10 Hz, the same keys the Icom console
|
||||||
|
// uses. Two consoles for two radios should not need two habits.
|
||||||
|
const ritRef = useRef({ on: false, hz: 0, off: true });
|
||||||
|
ritRef.current = { on: rit, hz: ritHz, off };
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
|
||||||
|
const r = ritRef.current;
|
||||||
|
if (r.off || !r.on) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const v = r.hz + (e.key === 'ArrowRight' ? 10 : -10);
|
||||||
|
setHold('rit_hz', v, ritRef.current.hz);
|
||||||
|
SetTCIRITOffset(v).catch(() => {});
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||||
|
{/* Capped and centred, like the Elecraft, Yaesu, Icom and Flex consoles.
|
||||||
|
Stretched across a wide window a console puts each slider a hand's
|
||||||
|
width from its own label and stops reading as one instrument. */}
|
||||||
|
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||||
|
{/* VFO + identity */}
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Radio className="size-3.5" />
|
||||||
|
{st.device || 'SunSDR'} {st.protocol ? `· ${st.protocol}` : ''}
|
||||||
|
<span className={cn('size-2 rounded-full', off ? 'bg-muted-foreground/40' : 'bg-success')} />
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-mono tabular-nums font-bold">
|
||||||
|
{freqHz > 0 ? (freqHz / 1e6).toFixed(6) : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{st.tx && <span className="rounded-md bg-danger px-2 py-1 text-[11px] font-bold text-danger-foreground">TX</span>}
|
||||||
|
{st.split && <span className="rounded-md border border-border px-2 py-1 text-[11px] font-bold">SPLIT</span>}
|
||||||
|
<Toggle label={t('tcip.lock')} on={st.lock} off={off} onClick={() => call(() => SetTCILock(!st.lock))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{off && <div className="text-xs text-muted-foreground px-1">{t('tcip.waiting')}</div>}
|
||||||
|
{!!err && <div className="text-[11px] text-danger px-1">{err}</div>}
|
||||||
|
|
||||||
|
{/* Meters. The S-meter while receiving, power and SWR while
|
||||||
|
transmitting — the radio answers TX_POWER and TX_SWR only when it is
|
||||||
|
keyed, so showing them the rest of the time would be showing the
|
||||||
|
last thing that happened as if it were now.
|
||||||
|
There is no temperature: the protocol has no such command, and a
|
||||||
|
made-up figure on a transmitter is the kind somebody trusts. */}
|
||||||
|
<Card icon={Activity} title={t('tcip.meters')}>
|
||||||
|
<MeterBar label="S-METER" value={st.tx || st.tuning ? 0 : sBar(st.smeter)} lo={0} hi={100}
|
||||||
|
accent="#16a34a" segColor={sSegColor}
|
||||||
|
display={st.tx || st.tuning ? '—' : `${s.label} ${st.smeter} dBm`}
|
||||||
|
onClick={() => {
|
||||||
|
if (st.tx || !onReportRST) return;
|
||||||
|
onReportRST(sMeterRST(s.s, s.over, mode));
|
||||||
|
}}
|
||||||
|
title={t('tcip.sMeterHint')} />
|
||||||
|
{(st.tx || st.tuning) && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<MeterBar label="PWR" value={st.tx_power_w} lo={0} hi={Math.max(10, Math.ceil(st.tx_power_w / 10) * 10)}
|
||||||
|
accent="#0ea5e9" display={`${st.tx_power_w.toFixed(1)} W`} />
|
||||||
|
{/* 0 is "not measured yet", and it must not draw as a perfect
|
||||||
|
match: an SWR of 1.0 on an antenna nobody has measured is the
|
||||||
|
one reading an operator should not be handed. */}
|
||||||
|
<MeterBar label="SWR" value={st.tx_swr > 0 ? Math.min(100, (st.tx_swr - 1) * 50) : 0} lo={0} hi={100}
|
||||||
|
accent="#f59e0b" display={st.tx_swr > 0 ? st.tx_swr.toFixed(1) : '—'}
|
||||||
|
segColor={(f) => (f > 0.5 ? '#dc2626' : f > 0.25 ? '#f59e0b' : '#16a34a')} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Transmit */}
|
||||||
|
<Card icon={SlidersHorizontal} title={t('tcip.transmit')}>
|
||||||
|
{/* One level per ROW, full width. Two half-width sliders side by side
|
||||||
|
left each of them a couple of centimetres long — small enough that
|
||||||
|
setting 15% took aim. */}
|
||||||
|
<LevelRow label={t('tcip.drive')} unit="%" value={drive} disabled={off}
|
||||||
|
onSet={(v) => { setHold('drive', v, st.drive); call(() => SetTCIDrive(v)); }} />
|
||||||
|
<LevelRow label={t('tcip.tuneDrive')} unit="%" value={tuneDrive} disabled={off} accent="#f59e0b"
|
||||||
|
onSet={(v) => { setHold('tune_drive', v, st.tune_drive); call(() => SetTCITuneDrive(v)); }} />
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{/* TUNE transmits, and at the tune drive rather than the main one —
|
||||||
|
which is why both numbers are above the button rather than one of
|
||||||
|
them being in a menu somewhere. */}
|
||||||
|
<button type="button" disabled={off || !st.tx_enabled}
|
||||||
|
onClick={() => call(() => SetTCITune(!st.tuning))}
|
||||||
|
className={cn('rounded-lg border-2 px-4 py-2 text-sm font-extrabold tracking-wide transition-all disabled:opacity-30',
|
||||||
|
st.tuning ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50'
|
||||||
|
: 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
|
{st.tuning ? t('tcip.tuning') : t('tcip.tune')}
|
||||||
|
</button>
|
||||||
|
{!st.tx_enabled && !off && (
|
||||||
|
<span className="text-[11px] text-muted-foreground">{t('tcip.txDisabled')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<LevelRow label={t('tcip.mic')} unit="%" value={mic} disabled={off} accent="#a855f7"
|
||||||
|
onSet={(v) => { setHold('mic', v, st.mic_level); call(() => SetTCIMicLevel(v)); }} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Receive */}
|
||||||
|
<Card icon={AudioLines} title={t('tcip.receive')}>
|
||||||
|
{/* Both in the radio's OWN units — volume in dB, negative, and the
|
||||||
|
squelch as a dBm threshold — so the numbers match the ones in
|
||||||
|
ExpertSDR3's window rather than being percentages of something. */}
|
||||||
|
<LevelRow label={t('tcip.volume')} unit=" dB" min={-60} max={0} value={vol} disabled={off}
|
||||||
|
onSet={(v) => { setHold('vol', v, st.volume); call(() => SetTCIVolume(v)); }} />
|
||||||
|
<LevelRow label={t('tcip.squelch')} unit=" dBm" min={-140} max={0} value={sql}
|
||||||
|
disabled={off || !sqlOn} accent="#38bdf8"
|
||||||
|
onSet={(v) => { setHold('sql', v, st.squelch); call(() => SetTCISquelchLevel(v)); }} />
|
||||||
|
<div className={cn('grid gap-2', isCW(mode) ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-3 sm:grid-cols-5')}>
|
||||||
|
<Toggle label="NB" on={nb} off={off} onClick={() => { setHold('nb', !nb, st.nb); call(() => SetTCINB(!nb)); }} />
|
||||||
|
<Toggle label="NR" on={nr} off={off} onClick={() => { setHold('nr', !nr, st.nr); call(() => SetTCINR(!nr)); }} />
|
||||||
|
<Toggle label="ANF" on={anf} off={off} onClick={() => { setHold('anf', !anf, st.anf); call(() => SetTCIANF(!anf)); }} />
|
||||||
|
{/* APF is an audio PEAK filter — it rings a single tone out of the
|
||||||
|
noise, which is a CW tool and nothing else. Shown only there:
|
||||||
|
off CW it is not a control, it is a puzzle. */}
|
||||||
|
{isCW(mode) && (
|
||||||
|
<Toggle label="APF" on={apf} off={off} onClick={() => { setHold('apf', !apf, st.apf); call(() => SetTCIAPF(!apf)); }} />
|
||||||
|
)}
|
||||||
|
<Toggle label="SQL" on={sqlOn} off={off} onClick={() => { setHold('sql_on', !sqlOn, st.squelch_on); call(() => SetTCISquelch(!sqlOn)); }} />
|
||||||
|
<Toggle label={t('tcip.mute')} on={muted} off={off} onClick={() => { setHold('mute', !muted, st.mute); call(() => SetTCIMute(!muted)); }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{t('tcip.agc')}</span>
|
||||||
|
{/* LONG is gone. The protocol accepts it, but it is a hang time
|
||||||
|
nobody reaches for between overs, and a fifth button that has to
|
||||||
|
be explained is worse than four that do not. */}
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{['off', 'slow', 'med', 'fast'].map((m) => (
|
||||||
|
<Toggle key={m} label={m.toUpperCase()} on={agc === m} off={off}
|
||||||
|
onClick={() => { setHold('agc', m, st.agc || ''); call(() => SetTCIAGC(m)); }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{t('tcip.filter')}</span>
|
||||||
|
<span className="text-xs font-mono tabular-nums">{st.filter_lo}–{st.filter_hi} Hz</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pb-1">
|
||||||
|
<LevelRow label="LO" unit=" Hz" min={-5000} max={5000} step={10}
|
||||||
|
value={st.filter_lo} disabled={off}
|
||||||
|
onSet={(v) => call(() => SetTCIFilter(v, st.filter_hi))} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pb-1">
|
||||||
|
<LevelRow label="HI" unit=" Hz" min={-5000} max={5000} step={10}
|
||||||
|
value={st.filter_hi} disabled={off}
|
||||||
|
onSet={(v) => call(() => SetTCIFilter(st.filter_lo, v))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-7 gap-2">
|
||||||
|
{widthsFor(mode).map((w) => {
|
||||||
|
const e = edgesFor(w, mode);
|
||||||
|
// Lit by the WIDTH the radio is actually using, not by an exact
|
||||||
|
// pair of edges: the operator may have moved one edge on the
|
||||||
|
// radio, and a button that only lights on our own numbers would
|
||||||
|
// go dark for a filter that is plainly 500 Hz wide.
|
||||||
|
const on = Math.abs((st.filter_hi - st.filter_lo) - w) <= 50;
|
||||||
|
return (
|
||||||
|
<Toggle key={w} label={widthLabel(w)} on={on} off={off}
|
||||||
|
title={`${e.lo}–${e.hi} Hz`}
|
||||||
|
onClick={() => call(() => SetTCIFilter(e.lo, e.hi))} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* RIT / XIT — the SAME control the Icom console uses, now shared
|
||||||
|
rather than reinvented: a chip, a signed offset you can type into,
|
||||||
|
scroll on, or step with ±, and a zero. Ctrl+←/→ shifts the RIT. */}
|
||||||
|
<Card icon={Mic} title="RIT / XIT">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<ShiftRow label="RIT" on={rit} hz={ritHz} accent="#38bdf8" disabled={off}
|
||||||
|
onToggle={() => { setHold('rit', !rit, st.rit); call(() => SetTCIRIT(!rit)); }}
|
||||||
|
onSet={(v) => { setHold('rit_hz', v, st.rit_offset); call(() => SetTCIRITOffset(v)); }} />
|
||||||
|
<ShiftRow label="XIT" on={xit} hz={xitHz} accent="#f59e0b" disabled={off}
|
||||||
|
onToggle={() => { setHold('xit', !xit, st.xit); call(() => SetTCIXIT(!xit)); }}
|
||||||
|
onSet={(v) => { setHold('xit_hz', v, st.xit_offset); call(() => SetTCIXITOffset(v)); }} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
|||||||
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
|
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
|
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
|
||||||
|
GetWsjtHighlight, SetWsjtHighlight, GetWsjtHighlightWorked, SetWsjtHighlightWorked, GetWsjtFollowMode, SetWsjtFollowMode,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -158,6 +159,15 @@ const TRIGGERS = [
|
|||||||
type Props = { onError: (msg: string) => void };
|
type Props = { onError: (msg: string) => void };
|
||||||
|
|
||||||
export function UDPIntegrationsPanel({ onError }: Props) {
|
export function UDPIntegrationsPanel({ onError }: Props) {
|
||||||
|
const [highlightOn, setHighlightOn] = useState(false);
|
||||||
|
const [hlWorked, setHlWorked] = useState(false);
|
||||||
|
const [followMode, setFollowMode] = useState(true);
|
||||||
|
useEffect(() => {
|
||||||
|
GetWsjtHighlight().then((v) => setHighlightOn(!!v)).catch(() => {});
|
||||||
|
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
|
||||||
|
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
|
||||||
|
GetWsjtFollowMode().then((v) => setFollowMode(!!v)).catch(() => {});
|
||||||
|
}, []);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [items, setItems] = useState<UDPConfig[]>([]);
|
const [items, setItems] = useState<UDPConfig[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -229,10 +239,36 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
{/* Log-aware colours in WSJT-X / JTDX's own window — lives HERE because
|
||||||
{t('udpp.intro')}
|
this panel is where the WSJT-X link is configured. */}
|
||||||
</div>
|
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
|
||||||
|
<Checkbox checked={highlightOn}
|
||||||
|
onCheckedChange={(c) => { setHighlightOn(!!c); void SetWsjtHighlight(!!c); }} />
|
||||||
|
<span>
|
||||||
|
{t('udpp.highlight')}
|
||||||
|
<span className="block text-[11px] text-muted-foreground">{t('udpp.highlightHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{/* Nested under the switch above: the same feature, and meaningless
|
||||||
|
while that one is off. */}
|
||||||
|
{highlightOn && (
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl pl-6">
|
||||||
|
<Checkbox checked={hlWorked}
|
||||||
|
onCheckedChange={(c) => { setHlWorked(!!c); void SetWsjtHighlightWorked(!!c); }} />
|
||||||
|
<span>
|
||||||
|
{t('udpp.hlWorked')}
|
||||||
|
<span className="block text-[11px] text-muted-foreground">{t('udpp.hlWorkedHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
|
||||||
|
<Checkbox checked={followMode}
|
||||||
|
onCheckedChange={(c) => { setFollowMode(!!c); void SetWsjtFollowMode(!!c); }} />
|
||||||
|
<span>
|
||||||
|
{t('udpp.followMode')}
|
||||||
|
<span className="block text-[11px] text-muted-foreground">{t('udpp.followModeHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<Section
|
<Section
|
||||||
title={t('udpp.inboundTitle')}
|
title={t('udpp.inboundTitle')}
|
||||||
icon={<ArrowDownToLine className="size-4" />}
|
icon={<ArrowDownToLine className="size-4" />}
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
// Watchlist — the DXHunter concept as an OpsLog tab.
|
||||||
|
//
|
||||||
|
// One card per watched callsign (or prefix), the live spots that match it
|
||||||
|
// underneath, and the two questions an operator actually asks answered on every
|
||||||
|
// line: is this slot still NEEDED, and what would it be worth (the same NEW
|
||||||
|
// DXCC / band / mode / slot badges the cluster shows, resolved from the same
|
||||||
|
// index). A CONTEST entry is judged against the current UTC day — at midnight
|
||||||
|
// everything reads "work today" again; the boundary lives in the backend query,
|
||||||
|
// so there is nothing to reset.
|
||||||
|
//
|
||||||
|
// The design is DXHunter's — the layout, the badges, the wording — repainted in
|
||||||
|
// the app's theme tokens rather than its hard-coded slate/pink.
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Eye, Plus, Trash2, Bell, BellOff, Trophy, Search, Check, AlertTriangle } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import {
|
||||||
|
WatchlistEntries, WatchlistAdd, WatchlistRemove, WatchlistSetNotify,
|
||||||
|
WatchlistSetContest, WatchlistWorkedSlots,
|
||||||
|
GetWatchlistContestPattern, SetWatchlistContestPattern, OpenExternalURL,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
|
import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
|
||||||
|
import { inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||||
|
import { useWatchlistSpots, matchesEntry, newBadge, type WLEntry } from '@/lib/watchlistSpots';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
spots: ClusterSpot[];
|
||||||
|
spotStatus: Record<string, SpotStatusEntry>;
|
||||||
|
onSpotSelect?: (s: ClusterSpot) => void;
|
||||||
|
onSpotClick?: (s: ClusterSpot) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [entries, setEntries] = useState<WLEntry[]>([]);
|
||||||
|
const [addCall, setAddCall] = useState('');
|
||||||
|
const [addContest, setAddContest] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
// The filters survive leaving the tab: the component unmounts on every tab
|
||||||
|
// switch, and filters that reset each time are filters nobody trusts.
|
||||||
|
const [neededOnly, setNeededOnlyRaw] = useState(() => localStorage.getItem('opslog.wlNeededOnly') === '1');
|
||||||
|
const [activeOnly, setActiveOnlyRaw] = useState(() => localStorage.getItem('opslog.wlActiveOnly') === '1');
|
||||||
|
const [family, setFamilyRaw] = useState<'all' | 'normal' | 'contest'>(() => {
|
||||||
|
const v = localStorage.getItem('opslog.wlFamily');
|
||||||
|
return v === 'normal' || v === 'contest' ? v : 'all';
|
||||||
|
});
|
||||||
|
const setNeededOnly = (f: (v: boolean) => boolean) => setNeededOnlyRaw((v) => { const nv = f(v); try { localStorage.setItem('opslog.wlNeededOnly', nv ? '1' : '0'); } catch {} return nv; });
|
||||||
|
const setActiveOnly = (f: (v: boolean) => boolean) => setActiveOnlyRaw((v) => { const nv = f(v); try { localStorage.setItem('opslog.wlActiveOnly', nv ? '1' : '0'); } catch {} return nv; });
|
||||||
|
const setFamily = (v: 'all' | 'normal' | 'contest') => { setFamilyRaw(v); try { localStorage.setItem('opslog.wlFamily', v); } catch {} };
|
||||||
|
// Mode filter — DXHunter's "All Modes" select. DIGI matches the digital class
|
||||||
|
// (FT8, FT4, a generic DATA spot…), the named modes match exactly.
|
||||||
|
const [modeFilter, setModeFilterRaw] = useState(() => localStorage.getItem('opslog.wlMode') || 'ALL');
|
||||||
|
const setModeFilter = (v: string) => { setModeFilterRaw(v); try { localStorage.setItem('opslog.wlMode', v); } catch {} };
|
||||||
|
const modeMatches = (s2: ClusterSpot): boolean => {
|
||||||
|
if (modeFilter === 'ALL') return true;
|
||||||
|
const m = (inferSpotMode(s2.comment ?? '', s2.freq_hz) || '').toUpperCase();
|
||||||
|
if (modeFilter === 'DIGI') return !['', 'CW', 'SSB', 'USB', 'LSB', 'FM', 'AM'].includes(m);
|
||||||
|
if (modeFilter === 'SSB') return m === 'SSB' || m === 'USB' || m === 'LSB';
|
||||||
|
return m === modeFilter;
|
||||||
|
};
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
// A quiet confirmation under the toolbar; both messages clear themselves —
|
||||||
|
// a stale "added" from ten minutes ago reads as a fresh one.
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
|
const noticeTimer = useRef<number | undefined>(undefined);
|
||||||
|
const flash = (msg: string, isError: boolean) => {
|
||||||
|
if (noticeTimer.current) window.clearTimeout(noticeTimer.current);
|
||||||
|
setError(isError ? msg : '');
|
||||||
|
setNotice(isError ? '' : msg);
|
||||||
|
noticeTimer.current = window.setTimeout(() => { setError(''); setNotice(''); }, 4000) as unknown as number;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try { setEntries(((await WatchlistEntries()) ?? []) as any as WLEntry[]); }
|
||||||
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}, []);
|
||||||
|
// Refreshed on mount, when the backend auto-adds (event), and on a slow tick
|
||||||
|
// so last-seen and the spot counters stay honest while the tab sits open.
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
const off = EventsOn('watchlist:changed', () => { void refresh(); });
|
||||||
|
const id = window.setInterval(() => { void refresh(); }, 30_000);
|
||||||
|
return () => { off(); window.clearInterval(id); };
|
||||||
|
}, [refresh]);
|
||||||
|
// The auto-contest pattern (DXHunter's contest_prefix): spots whose call
|
||||||
|
// contains it are added as contest entries by the backend.
|
||||||
|
const [pattern, setPattern] = useState('');
|
||||||
|
useEffect(() => { GetWatchlistContestPattern().then((p: string) => setPattern(p ?? '')).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
// Live spots per entry — prefix-matched, newest first, deduped per band+mode
|
||||||
|
// (one line per slot; the freshest spot represents it).
|
||||||
|
// Which entries are on the air, and which of their slots are still needed.
|
||||||
|
// One definition, shared with the docked watch-list widget — the answer
|
||||||
|
// involves a debounced query per visible slot, and two copies of it would be
|
||||||
|
// two bursts of the same question and two ideas of what "needed" means.
|
||||||
|
const { spotsFor, workedFor, settled, onAir, worked } = useWatchlistSpots(entries, spots);
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
const c = addCall.trim().toUpperCase();
|
||||||
|
if (!c) return;
|
||||||
|
try {
|
||||||
|
await WatchlistAdd(c, addContest);
|
||||||
|
setAddCall('');
|
||||||
|
// The confirmation is the app-level notice (App.tsx, on watchlist:changed)
|
||||||
|
// — saying it twice, once per place a call can be added from, was noise.
|
||||||
|
await refresh();
|
||||||
|
} catch (e: any) { flash(String(e?.message ?? e), true); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// One click, like the bell and the trophy beside it: removing a watchlist
|
||||||
|
// entry is cheap to undo (type it again), so it does not earn a confirmation
|
||||||
|
// the other two buttons do not have.
|
||||||
|
const remove = async (call: string) => {
|
||||||
|
try { await WatchlistRemove(call); await refresh(); }
|
||||||
|
catch (e: any) { flash(String(e?.message ?? e), true); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOnAir = onAir;
|
||||||
|
|
||||||
|
const shown = entries.filter((e) => {
|
||||||
|
if (family === 'normal' && e.isContest) return false;
|
||||||
|
if (family === 'contest' && !e.isContest) return false;
|
||||||
|
if (search && !e.callsign.includes(search.trim().toUpperCase())) return false;
|
||||||
|
const list = (spotsFor.get(e.callsign) ?? []).filter(modeMatches).filter((s2) => settled(e, s2));
|
||||||
|
if (activeOnly && list.length === 0) return false;
|
||||||
|
if (neededOnly && !list.some((s2) => !workedFor(e, s2))) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const counters = useMemo(() => {
|
||||||
|
let active = 0, needed = 0;
|
||||||
|
for (const e of entries) {
|
||||||
|
const list = (spotsFor.get(e.callsign) ?? []).filter(modeMatches).filter((s2) => settled(e, s2));
|
||||||
|
if (list.length > 0) active++;
|
||||||
|
if (list.some((s2) => !workedFor(e, s2))) needed++;
|
||||||
|
}
|
||||||
|
return { total: entries.length, active, needed };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [entries, spotsFor, worked, modeFilter]);
|
||||||
|
|
||||||
|
// The cluster's own badge for a spot, read from the shared status index.
|
||||||
|
const dxccBadge = (s: ClusterSpot): { label: string; color: string } | null => {
|
||||||
|
const st = spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)];
|
||||||
|
const b = newBadge(st?.status);
|
||||||
|
return b ? { label: t(b.key), color: b.colour } : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Three decimals, and no trailing zeros beyond them: 7.056 rather than
|
||||||
|
// 7.0560, 14.0745 rather than 14.074500. DXHunter's own rule, and the one an
|
||||||
|
// operator reads a cluster line with.
|
||||||
|
const fmtMHz = (hz: number) => {
|
||||||
|
const [int, dec] = (hz / 1e6).toFixed(6).split('.');
|
||||||
|
return int + '.' + dec.slice(0, 3) + dec.slice(3).replace(/0+$/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const chip = (color: string, text: string, extra?: string) => (
|
||||||
|
<span className={cn('px-1.5 py-0.5 rounded text-[11px] font-semibold border', extra)}
|
||||||
|
style={{ color, borderColor: `color-mix(in srgb, ${color} 40%, transparent)`, background: `color-mix(in srgb, ${color} 12%, transparent)` }}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Capped and centred: a card is a reading surface, and callsign-to-badge
|
||||||
|
// lines stretched across a 34-inch window are not readable, they are long.
|
||||||
|
<div className="flex flex-col min-h-0 flex-1 gap-2 p-2 w-full max-w-5xl mx-auto">
|
||||||
|
{/* Header: the counters alone, centred — they are the tab's headline.
|
||||||
|
Everything one INTERACTS with lives on the second row. */}
|
||||||
|
<div className="flex items-center justify-center gap-2.5 text-xs text-muted-foreground">
|
||||||
|
<Eye className="size-4 text-primary shrink-0" />
|
||||||
|
<span className="flex items-center gap-1.5">{t('wl.cTotal')}
|
||||||
|
<b className="px-2 py-0.5 rounded bg-muted text-foreground">{counters.total}</b></span>
|
||||||
|
<span className="opacity-40">|</span>
|
||||||
|
<span className="flex items-center gap-1.5">{t('wl.cActive')}
|
||||||
|
<b className="px-2 py-0.5 rounded text-info border border-info/30 bg-info/10">{counters.active}</b></span>
|
||||||
|
<span className="opacity-40">|</span>
|
||||||
|
<span className="flex items-center gap-1.5">{t('wl.cNeeded')}
|
||||||
|
<b className={cn('px-2 py-0.5 rounded border', counters.needed > 0
|
||||||
|
? 'text-warning border-warning/40 bg-warning/10'
|
||||||
|
: 'text-muted-foreground border-border bg-muted/40')}>{counters.needed}</b></span>
|
||||||
|
</div>
|
||||||
|
{/* toolbar */}
|
||||||
|
<div className="flex items-start justify-between gap-x-3 gap-y-1.5 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Input className="h-8 w-40 font-mono placeholder:font-sans placeholder:normal-case" placeholder={t('wl.addPh')} value={addCall}
|
||||||
|
onChange={(e) => setAddCall(e.target.value.toUpperCase())}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') void add(); }} />
|
||||||
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('wl.contestHint')}>
|
||||||
|
<Checkbox checked={addContest} onCheckedChange={(c) => setAddContest(!!c)} />
|
||||||
|
<Trophy className="size-3.5 text-warning" /> {t('wl.addAsContest')}
|
||||||
|
</label>
|
||||||
|
<Button size="sm" className="h-8" onClick={() => void add()} disabled={!addCall.trim()}>
|
||||||
|
<Plus className="size-3.5" /> {t('wl.add')}
|
||||||
|
</Button>
|
||||||
|
<Input className="h-8 w-28 font-mono placeholder:font-sans placeholder:normal-case" placeholder={t('wl.patternPh')}
|
||||||
|
title={t('wl.patternHint')}
|
||||||
|
value={pattern}
|
||||||
|
onChange={(e) => setPattern(e.target.value.toUpperCase())}
|
||||||
|
onBlur={() => { void SetWatchlistContestPattern(pattern.trim()); }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input className="h-8 w-28 pl-7 text-sm" placeholder={t('wl.searchPh')} value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{/* families kept together, told apart — per review of the DXHunter port,
|
||||||
|
no global contest mode: each entry carries its own rule. */}
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
|
||||||
|
{([['all', t('wl.famAll')], ['normal', t('wl.famNormal')], ['contest', t('wl.famContest')]] as const).map(([k, label]) => (
|
||||||
|
<button key={k} type="button" onClick={() => setFamily(k)}
|
||||||
|
className={cn('px-2 py-1 border-l border-border first:border-l-0',
|
||||||
|
family === k ? 'bg-accent font-medium' : 'text-muted-foreground hover:bg-accent/50')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<select value={modeFilter} onChange={(e) => setModeFilter(e.target.value)}
|
||||||
|
title={t('wl.modeFilter')}
|
||||||
|
className="h-7 px-1.5 rounded-md border border-border bg-background text-xs text-foreground">
|
||||||
|
{['ALL', 'CW', 'SSB', 'FT8', 'FT4', 'RTTY', 'DIGI'].map((m) => (
|
||||||
|
<option key={m} value={m}>{m === 'ALL' ? t('wl.allModes') : m}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="button" onClick={() => setActiveOnly((v) => !v)}
|
||||||
|
className={cn('px-2 py-1 rounded-md border text-xs', activeOnly ? 'bg-accent border-border font-medium' : 'border-border text-muted-foreground hover:bg-accent/50')}>
|
||||||
|
{t('wl.activeOnly')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setNeededOnly((v) => !v)}
|
||||||
|
className={cn('px-2 py-1 rounded-md border text-xs', neededOnly ? 'bg-accent border-border font-medium' : 'border-border text-muted-foreground hover:bg-accent/50')}>
|
||||||
|
{t('wl.neededOnly')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-xs text-danger px-1">{error}</div>}
|
||||||
|
{notice && <div className="text-xs text-success px-1">{notice}</div>}
|
||||||
|
|
||||||
|
{/* cards */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto space-y-2 pr-1">
|
||||||
|
{shown.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground text-center pt-16 max-w-md mx-auto leading-relaxed">
|
||||||
|
{entries.length === 0 ? t('wl.empty') : t('wl.noneMatch')}
|
||||||
|
</div>
|
||||||
|
) : shown.map((e) => {
|
||||||
|
const all = (spotsFor.get(e.callsign) ?? []).filter(modeMatches).filter((s2) => settled(e, s2));
|
||||||
|
const needed = all.filter((s) => !workedFor(e, s)).length;
|
||||||
|
// Needed-only hides the worked LINES as well as the all-worked cards:
|
||||||
|
// a filter that says needed and still lists five green Worked rows is
|
||||||
|
// answering a different question than the one asked.
|
||||||
|
const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all;
|
||||||
|
return (
|
||||||
|
<div key={e.callsign}
|
||||||
|
className={cn('rounded border bg-card/70 p-3 transition-colors hover:bg-accent/20',
|
||||||
|
needed > 0 ? 'border-warning/40' : 'border-border/70',
|
||||||
|
e.isContest && 'border-l-4 border-l-warning')}>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{/* Proportional, not monospaced: DXHunter sets this one in the
|
||||||
|
interface font and the difference is the first thing an
|
||||||
|
operator notices with the two windows side by side. There is
|
||||||
|
nothing to align here — it is a heading, not a column. */}
|
||||||
|
<span className="text-lg font-bold" style={{ color: '#f472b6' }}>{e.callsign}</span>
|
||||||
|
{isOnAir(e) && chip('var(--danger)', t('wl.onAir'), 'animate-pulse')}
|
||||||
|
{e.isContest && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-semibold bg-warning-muted text-warning-muted-foreground border border-warning-border"
|
||||||
|
title={t('wl.contestHint')}>
|
||||||
|
<Trophy className="size-3" /> {t('wl.contest')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{e.isExpedition && chip('var(--chart-5)', '⚡ ' + t('wl.expedition'))}
|
||||||
|
{e.clubLogTotalQSOs > 0 && <span className="text-[11px] text-muted-foreground">{e.clubLogTotalQSOs.toLocaleString()} QSOs{e.clubLogQSOs24h > 0 ? ` · ${e.clubLogQSOs24h}/24h` : ''}</span>}
|
||||||
|
{e.clubLogHasOQRS && chip('var(--success)', 'OQRS')}
|
||||||
|
{e.clubLogLiveStream && (
|
||||||
|
<a href="#" onClick={(ev) => { ev.preventDefault(); void OpenExternalURL(`https://clublog.org/livestream/${e.callsign.replace('*', '')}`); }}
|
||||||
|
className="px-1.5 py-0.5 rounded text-[11px] font-semibold border text-info border-info/40 bg-info/10 hover:bg-info/20">Live</a>
|
||||||
|
)}
|
||||||
|
{list.length > 0 && (needed > 0
|
||||||
|
? chip('var(--warning)', e.isContest ? t('wl.nToday', { n: needed }) : t('wl.nNeeded', { n: needed }))
|
||||||
|
: chip('var(--success)', e.isContest ? t('wl.workedToday') : t('wl.allWorked')))}
|
||||||
|
{e.lastSeenStr && e.lastSeenStr !== 'Never' && (
|
||||||
|
<span className="text-[11px] text-muted-foreground">• {e.lastSeenStr}</span>
|
||||||
|
)}
|
||||||
|
{e.spotCount > 0 && (
|
||||||
|
<span className="text-[11px] text-muted-foreground/70">• {t('wl.totalSpots', { n: e.spotCount })}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button type="button" title={t('wl.toggleContest')}
|
||||||
|
onClick={() => void WatchlistSetContest(e.callsign, !e.isContest).then(refresh)}
|
||||||
|
className={cn('p-1 rounded hover:bg-muted', e.isContest ? 'text-warning' : 'text-muted-foreground/40')}>
|
||||||
|
<Trophy className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button type="button" title={e.notify ? t('wl.notifyOff') : t('wl.notifyOn')}
|
||||||
|
onClick={() => void WatchlistSetNotify(e.callsign, !e.notify).then(refresh)}
|
||||||
|
className={cn('p-1 rounded hover:bg-muted', e.notify ? 'text-warning' : 'text-muted-foreground/40')}>
|
||||||
|
{e.notify ? <Bell className="size-3.5" /> : <BellOff className="size-3.5" />}
|
||||||
|
</button>
|
||||||
|
<button type="button" title={t('wl.remove')}
|
||||||
|
onClick={() => void remove(e.callsign)}
|
||||||
|
className="p-1 rounded hover:bg-muted text-muted-foreground/40 hover:text-danger">
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{list.length > 0 ? (
|
||||||
|
<div className="mt-2 space-y-1 max-h-52 overflow-y-auto">
|
||||||
|
{list.slice(0, 12).map((s, i) => {
|
||||||
|
const done = workedFor(e, s);
|
||||||
|
const badge = dxccBadge(s);
|
||||||
|
const mode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||||
|
return (
|
||||||
|
<button key={i} type="button"
|
||||||
|
onClick={() => onSpotSelect?.(s)}
|
||||||
|
onDoubleClick={() => onSpotClick?.(s)}
|
||||||
|
title={t('wl.spotTip')}
|
||||||
|
className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/25 hover:bg-muted/70 transition-colors text-left',
|
||||||
|
!done && 'border-l-2 border-warning')}>
|
||||||
|
{/* Worked or wanted, said with a symbol at the head of
|
||||||
|
the line as well as with the stripe down its side —
|
||||||
|
the same two marks DXHunter uses, and the one an eye
|
||||||
|
finds first when a card holds ten rows. */}
|
||||||
|
{done
|
||||||
|
? <Check className="size-4 shrink-0 text-success" />
|
||||||
|
: <AlertTriangle className="size-4 shrink-0 text-warning" />}
|
||||||
|
{/* Fixed columns: an elastic country made band/mode/freq start wherever the name ended — every row its own ruler. */}
|
||||||
|
<span className="font-bold text-info shrink-0 w-24 truncate">{s.dx_call}</span>
|
||||||
|
<span className="text-muted-foreground truncate shrink-0 w-44">{(s as any).country ?? ''}</span>
|
||||||
|
<span className="px-1.5 rounded bg-muted shrink-0 w-11 text-center">{s.band}</span>
|
||||||
|
<span className="px-1.5 rounded shrink-0 w-11 text-center" style={mode ? { color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' } : undefined}>{mode || ' '}</span>
|
||||||
|
<span className="font-mono text-muted-foreground shrink-0 w-16 text-right">{fmtMHz(s.freq_hz)}</span>
|
||||||
|
{badge && chip(badge.color, badge.label)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{done
|
||||||
|
? chip('var(--success)', e.isContest ? t('wl.todayOk') : t('wl.worked'))
|
||||||
|
: chip('var(--warning)', e.isContest ? t('wl.workToday') : t('wl.needed'))}
|
||||||
|
<span className="text-muted-foreground/70 shrink-0">{s.time_utc ?? ''}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 text-[11px] text-muted-foreground text-center py-1.5 bg-muted/30 rounded">
|
||||||
|
{t('wl.noSpots')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// WatchlistWidget — the watch list reduced to what is worth acting on RIGHT
|
||||||
|
// NOW: entries that are on the air and still needed.
|
||||||
|
//
|
||||||
|
// The Watchlist tab is a tab, and an operator working FT8 lives on the decodes
|
||||||
|
// one. A station they asked to be told about would appear on a screen they are
|
||||||
|
// not looking at — so the same answer is docked in the widget strip, which sits
|
||||||
|
// above the tabs and is therefore always in view. Only active AND needed: a
|
||||||
|
// list of everything watched is the tab's job, and it would not fit here.
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Bell, X } from 'lucide-react';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||||
|
import { useWatchlistSpots, newBadge, type WLEntry } from '@/lib/watchlistSpots';
|
||||||
|
import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
|
||||||
|
import { WatchlistEntries } from '../../wailsjs/go/main/App';
|
||||||
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
spots: ClusterSpot[];
|
||||||
|
// The cluster's own verdicts. WHAT a station is worth is the reason to leave
|
||||||
|
// what you are doing for it — "on the air and not worked" says nothing about
|
||||||
|
// whether it is a new entity or a fifth band on one worked in 1998.
|
||||||
|
spotStatus: Record<string, SpotStatusEntry>;
|
||||||
|
// A row is a spot: clicking it does what clicking one in the cluster does.
|
||||||
|
onPick?: (s: ClusterSpot) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function age(s: ClusterSpot): string {
|
||||||
|
const ts = Date.parse(String((s as any).received_at ?? ''));
|
||||||
|
if (!(ts > 0)) return '';
|
||||||
|
const m = Math.floor((Date.now() - ts) / 60000);
|
||||||
|
if (m < 1) return 'now';
|
||||||
|
return `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WatchlistWidget({ spots, spotStatus, onPick, onClose }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [entries, setEntries] = useState<WLEntry[]>([]);
|
||||||
|
|
||||||
|
const load = () => { WatchlistEntries().then((e: any) => setEntries((e ?? []) as WLEntry[])).catch(() => {}); };
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// The list changes from four places (the tab, the cluster's star, the
|
||||||
|
// contest auto-add, an import), and a widget that only reads it at mount
|
||||||
|
// would quietly watch the wrong set for the rest of the session.
|
||||||
|
const off = EventsOn('watchlist:changed', load);
|
||||||
|
return () => { off?.(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { spotsFor, workedFor, settled } = useWatchlistSpots(entries, spots);
|
||||||
|
|
||||||
|
// Ticking, because every row carries an age and the freshest thing here is
|
||||||
|
// the reason to look at it at all.
|
||||||
|
const [, tick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => tick((n) => n + 1), 30000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// One row per (entry, needed slot): the same station on two bands is two
|
||||||
|
// chances to work it, and collapsing them would hide the one that is open.
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const out: { e: WLEntry; s: ClusterSpot }[] = [];
|
||||||
|
for (const e of entries) {
|
||||||
|
for (const s of spotsFor.get(e.callsign) ?? []) {
|
||||||
|
if (!settled(e, s) || workedFor(e, s)) continue;
|
||||||
|
out.push({ e, s });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Freshest first: a spot from twenty minutes ago is history, and this
|
||||||
|
// panel is short.
|
||||||
|
return out.sort((a, b) =>
|
||||||
|
Date.parse(String((b.s as any).received_at ?? '')) - Date.parse(String((a.s as any).received_at ?? '')));
|
||||||
|
}, [entries, spotsFor, workedFor, settled]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
||||||
|
<Bell className="size-4 text-primary shrink-0" />
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t('wlw.title')}
|
||||||
|
</span>
|
||||||
|
<span className={cn('rounded px-1.5 py-px text-[10px] font-bold',
|
||||||
|
rows.length > 0 ? 'bg-warning text-warning-foreground' : 'bg-muted text-muted-foreground')}>
|
||||||
|
{rows.length}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{onClose && (
|
||||||
|
<button type="button" onClick={onClose} title={t('wlw.hide')}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
// Empty is the normal state, and it means something precise — say it,
|
||||||
|
// rather than leaving a blank box that reads as broken.
|
||||||
|
<div className="px-3 py-3 text-xs text-muted-foreground">
|
||||||
|
{entries.length === 0 ? t('wlw.emptyList') : t('wlw.emptyNone')}
|
||||||
|
</div>
|
||||||
|
) : rows.map(({ e, s }, i) => {
|
||||||
|
const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
|
||||||
|
const badge = newBadge(spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.status);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${e.callsign}-${s.dx_call}-${s.band}-${mode}-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick?.(s)}
|
||||||
|
title={e.callsign === s.dx_call
|
||||||
|
? t('wlw.pick', { call: s.dx_call })
|
||||||
|
: `${t('wlw.pick', { call: s.dx_call })} — ${e.callsign}`}
|
||||||
|
className="w-full text-left px-2 py-1 border-b border-border/20 hover:bg-muted/50 transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{/* One line, in reading order: who, where, what it is worth, how
|
||||||
|
long ago. The callsign is the only part allowed to give way —
|
||||||
|
everything else is short and fixed, and a row that wraps is a
|
||||||
|
row an operator has to parse instead of scan. */}
|
||||||
|
<span className="font-mono text-[13px] font-bold text-warning truncate">{s.dx_call}</span>
|
||||||
|
{s.band && (
|
||||||
|
<span className="rounded px-1 py-px text-[10px] font-bold uppercase bg-info-muted text-info-muted-foreground shrink-0">
|
||||||
|
{s.band}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{mode && <span className="font-mono text-[10px] text-muted-foreground shrink-0">{mode}</span>}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{/* Why it is worth interrupting a QSO for — or not. */}
|
||||||
|
{badge && (
|
||||||
|
<span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide border shrink-0"
|
||||||
|
style={{ color: badge.colour, borderColor: `color-mix(in srgb, ${badge.colour} 45%, transparent)`,
|
||||||
|
background: `color-mix(in srgb, ${badge.colour} 12%, transparent)` }}>
|
||||||
|
{t(badge.key)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground/70 tabular-nums shrink-0 w-7 text-right">
|
||||||
|
{age(s)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ interface Props {
|
|||||||
wpm: number;
|
wpm: number;
|
||||||
macros: WKMacro[];
|
macros: WKMacro[];
|
||||||
sent: string; // text echoed back by the keyer as it transmits
|
sent: string; // text echoed back by the keyer as it transmits
|
||||||
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood'; // CW output engine (chosen in Settings → CW Keyer)
|
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood' | 'tci'; // CW output engine (chosen in Settings → CW Keyer)
|
||||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||||
onSetBreakIn?: (mode: number) => void;
|
onSetBreakIn?: (mode: number) => void;
|
||||||
onSelectPort: (p: string) => void;
|
onSelectPort: (p: string) => void;
|
||||||
@@ -109,16 +109,16 @@ export function WinkeyerPanel({
|
|||||||
<Radio className="size-4 text-primary shrink-0" />
|
<Radio className="size-4 text-primary shrink-0" />
|
||||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||||
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : source === 'kenwood' ? 'Kenwood CW' : 'WinKeyer'}
|
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : source === 'kenwood' ? 'Kenwood CW' : source === 'tci' ? 'TCI CW' : 'WinKeyer'}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' ? (
|
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' || source === 'tci' ? (
|
||||||
<span className="text-[11px] font-medium text-muted-foreground">
|
<span className="text-[11px] font-medium text-muted-foreground">
|
||||||
{source === 'flex'
|
{source === 'flex'
|
||||||
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||||
: source === 'yaesu' || source === 'kenwood'
|
: source === 'yaesu' || source === 'kenwood' || source === 'tci'
|
||||||
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
||||||
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { ChevronDown } from 'lucide-react';
|
import { ChevronDown } from 'lucide-react';
|
||||||
import { Input } from './input';
|
import { Input } from './input';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -36,6 +37,10 @@ export function Combobox({
|
|||||||
// otherwise "open" onto COM7 alone.
|
// otherwise "open" onto COM7 alone.
|
||||||
const [browse, setBrowse] = useState(false);
|
const [browse, setBrowse] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
// Where the portalled menu goes. Measured from the field itself, and
|
||||||
|
// re-measured while it is open, so scrolling the panel underneath does not
|
||||||
|
// leave the list floating over the wrong row.
|
||||||
|
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onDoc(e: MouseEvent) {
|
function onDoc(e: MouseEvent) {
|
||||||
@@ -45,6 +50,28 @@ export function Combobox({
|
|||||||
return () => document.removeEventListener('mousedown', onDoc);
|
return () => document.removeEventListener('mousedown', onDoc);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const place = () => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
// Opens UPWARD when there is not enough room below — which is exactly
|
||||||
|
// where these fields tend to sit, at the bottom of a panel.
|
||||||
|
const height = 240;
|
||||||
|
const below = window.innerHeight - r.bottom;
|
||||||
|
const top = below < height + 8 ? Math.max(4, r.top - height - 4) : r.bottom + 4;
|
||||||
|
setMenuPos({ top, left: r.left, width: r.width });
|
||||||
|
};
|
||||||
|
place();
|
||||||
|
window.addEventListener('scroll', place, true);
|
||||||
|
window.addEventListener('resize', place);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', place, true);
|
||||||
|
window.removeEventListener('resize', place);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
const filtered = !open ? []
|
const filtered = !open ? []
|
||||||
: browse ? options.slice(0, 60)
|
: browse ? options.slice(0, 60)
|
||||||
: options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60);
|
: options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60);
|
||||||
@@ -121,8 +148,16 @@ export function Combobox({
|
|||||||
<ChevronDown className="size-3.5" />
|
<ChevronDown className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{open && filtered.length > 0 && (
|
{/* THE MENU IS PORTALLED to the body, and positioned from the field's own
|
||||||
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-card shadow-lg text-xs">
|
rectangle. As an absolutely-positioned child it was clipped by whichever
|
||||||
|
scrolling or overflow-hidden box it happened to sit in: in the details
|
||||||
|
panel it was cut off after the first row, and a list showing one entry
|
||||||
|
of eight is worse than no list at all. */}
|
||||||
|
{open && filtered.length > 0 && createPortal(
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', top: menuPos.top, left: menuPos.left, width: menuPos.width }}
|
||||||
|
className="z-[100] max-h-60 overflow-auto rounded-md border border-border bg-card shadow-lg text-xs"
|
||||||
|
>
|
||||||
{filtered.map((o) => (
|
{filtered.map((o) => (
|
||||||
<button
|
<button
|
||||||
key={o}
|
key={o}
|
||||||
@@ -134,7 +169,8 @@ export function Combobox({
|
|||||||
{o}
|
{o}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
// Auto-call: let OpsLog answer a decode without the operator clicking it.
|
|
||||||
//
|
|
||||||
// WITHDRAWN FROM THE INTERFACE, because DXHunter already does it.
|
|
||||||
//
|
|
||||||
// Two programs from the same shack deciding on their own to answer the same
|
|
||||||
// decode is worse than either doing it alone: they cannot see each other, so
|
|
||||||
// they key over one another, and afterwards there is no telling which of them
|
|
||||||
// called. The duplicate is the one to remove, and DXHunter is where this lives.
|
|
||||||
//
|
|
||||||
// There is no switch in Preferences and no button on the decodes toolbar, and
|
|
||||||
// App.tsx returns before the loop can run. The file is kept whole: the rules
|
|
||||||
// below are the delicate part, argued over and tested, and rewriting them from
|
|
||||||
// memory later would be worse than leaving them here. Reinstating the feature
|
|
||||||
// means restoring all three — the settings page, the button, and the guard.
|
|
||||||
//
|
|
||||||
// This KEYS THE TRANSMITTER on its own, which is why the rules here are written
|
|
||||||
// as a series of refusals rather than a search for a reason to call. Everything
|
|
||||||
// below has to be true; anything unknown means no.
|
|
||||||
//
|
|
||||||
// The decision is made here, in one pure function, precisely because it is the
|
|
||||||
// dangerous part: it can be read, argued with and tested without a radio.
|
|
||||||
|
|
||||||
export type AutoCallCriteria = {
|
|
||||||
dxcc: boolean; // entity never worked
|
|
||||||
bandmode: boolean; // entity worked, but neither this band nor this mode
|
|
||||||
band: boolean; // entity never worked on this band
|
|
||||||
mode: boolean; // entity never worked in this mode
|
|
||||||
slot: boolean; // band and mode each worked, never together
|
|
||||||
grid: boolean; // square wanted under the grid scope
|
|
||||||
county: boolean; // US county never worked
|
|
||||||
pota: boolean; // park never worked
|
|
||||||
// No SOTA here, though the shape invites it: a decode carries no summit
|
|
||||||
// reference and the backend publishes no "new summit" flag, so a criterion
|
|
||||||
// for it could never be true. It WAS declared, translated and impossible to
|
|
||||||
// tick — a field that lies about what the feature can do.
|
|
||||||
pfx: boolean; // CQ WPX prefix never worked
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AutoCallSettings = {
|
|
||||||
enabled: boolean;
|
|
||||||
criteria: AutoCallCriteria;
|
|
||||||
// Callsigns to answer on sight, wildcards allowed (4S7*, */P). Each is still
|
|
||||||
// subject to `watchCriteria` — "call TM0HQ, but only if it is a new band" is
|
|
||||||
// the request, not "call it every time it appears".
|
|
||||||
watch: string[];
|
|
||||||
// Empty means call a watched callsign whenever it is not already worked.
|
|
||||||
watchCriteria: AutoCallCriteria;
|
|
||||||
// Seconds to ignore a callsign after calling it, so a station that keeps
|
|
||||||
// sending CQ is not re-answered every slot while the QSO is in progress.
|
|
||||||
cooldownSec: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const emptyCriteria: AutoCallCriteria = {
|
|
||||||
dxcc: false, bandmode: false, band: false, mode: false, slot: false,
|
|
||||||
grid: false, county: false, pota: false, pfx: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const defaultAutoCall: AutoCallSettings = {
|
|
||||||
// OFF, and it stays off until asked for. Unattended transmit is not something
|
|
||||||
// to inherit from an upgrade.
|
|
||||||
enabled: false,
|
|
||||||
criteria: { ...emptyCriteria },
|
|
||||||
watch: [],
|
|
||||||
watchCriteria: { ...emptyCriteria },
|
|
||||||
cooldownSec: 120,
|
|
||||||
};
|
|
||||||
|
|
||||||
const AC_KEY = 'opslog.autoCall';
|
|
||||||
|
|
||||||
export function loadAutoCall(): AutoCallSettings {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(AC_KEY);
|
|
||||||
if (!raw) return { ...defaultAutoCall };
|
|
||||||
const v = JSON.parse(raw);
|
|
||||||
const out: AutoCallSettings = {
|
|
||||||
...defaultAutoCall,
|
|
||||||
...v,
|
|
||||||
criteria: { ...emptyCriteria, ...(v?.criteria ?? {}) },
|
|
||||||
watchCriteria: { ...emptyCriteria, ...(v?.watchCriteria ?? {}) },
|
|
||||||
watch: Array.isArray(v?.watch) ? v.watch : [],
|
|
||||||
};
|
|
||||||
// DISARMED ON SIGHT, and written back disabled.
|
|
||||||
//
|
|
||||||
// The runtime guard in App.tsx stops this build from calling anyone, but it
|
|
||||||
// leaves "enabled": true sitting in storage, where any build without the
|
|
||||||
// guard — an older one an operator reinstalls, a machine that upgrades
|
|
||||||
// later — reads it and keys the transmitter for a feature with no switch
|
|
||||||
// left to turn off. A withdrawn feature that keys a radio has to be
|
|
||||||
// disarmed where it is REMEMBERED, not only where it runs.
|
|
||||||
if (out.enabled) {
|
|
||||||
out.enabled = false;
|
|
||||||
try { localStorage.setItem(AC_KEY, JSON.stringify(out)); } catch { /* private mode: the guard still holds */ }
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
} catch { return { ...defaultAutoCall }; }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const autoCallKey = AC_KEY;
|
|
||||||
|
|
||||||
// A decode's resolved novelty, the same shape the panel already renders from.
|
|
||||||
export type DecodeStatus = {
|
|
||||||
status?: string;
|
|
||||||
worked_call?: boolean;
|
|
||||||
new_grid?: boolean;
|
|
||||||
grid_state?: string;
|
|
||||||
new_county?: boolean;
|
|
||||||
new_pota?: boolean;
|
|
||||||
new_pfx?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
// matchesWildcard is the same rule the alert filters use: * is any run, ? is one.
|
|
||||||
export function matchesWildcard(pattern: string, call: string): boolean {
|
|
||||||
const p = pattern.trim().toUpperCase();
|
|
||||||
const c = call.trim().toUpperCase();
|
|
||||||
if (!p) return false;
|
|
||||||
const re = new RegExp('^' + p.split('').map((ch) => (
|
|
||||||
ch === '*' ? '.*' : ch === '?' ? '.' : ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
)).join('') + '$');
|
|
||||||
return re.test(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
// anyCriterion is false for an all-off set, which is what makes "watch this
|
|
||||||
// callsign, no conditions" expressible.
|
|
||||||
function anyCriterion(c: AutoCallCriteria): boolean {
|
|
||||||
return Object.values(c).some(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
// meets reports whether a decode satisfies at least one ticked criterion.
|
|
||||||
function meets(c: AutoCallCriteria, e: DecodeStatus): boolean {
|
|
||||||
if (c.dxcc && e.status === 'new') return true;
|
|
||||||
// Each status is exclusive, so a station that is new on both counts matches
|
|
||||||
// ONLY this criterion — ticking "new band" alone would not catch it, which is
|
|
||||||
// the wrong way round: it is the better catch of the two.
|
|
||||||
if (c.bandmode && e.status === 'new-band-mode') return true;
|
|
||||||
if (c.band && e.status === 'new-band') return true;
|
|
||||||
if (c.mode && e.status === 'new-mode') return true;
|
|
||||||
if (c.slot && e.status === 'new-slot') return true;
|
|
||||||
// A square that is merely UNCONFIRMED is not called: the QSO is already made,
|
|
||||||
// and calling again would work a duplicate to chase a QSL.
|
|
||||||
if (c.grid && e.new_grid && e.grid_state !== 'unconf') return true;
|
|
||||||
if (c.county && e.new_county) return true;
|
|
||||||
if (c.pota && e.new_pota) return true;
|
|
||||||
if (c.pfx && e.new_pfx) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AutoCallDecode = {
|
|
||||||
call: string;
|
|
||||||
cq?: boolean;
|
|
||||||
msg?: string;
|
|
||||||
instance?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// shouldAutoCall decides whether to answer one decode. The reason is returned
|
|
||||||
// for the log: an automatic transmission with no record of WHY is the thing an
|
|
||||||
// operator cannot argue with after the fact.
|
|
||||||
export function shouldAutoCall(
|
|
||||||
s: AutoCallSettings,
|
|
||||||
d: AutoCallDecode,
|
|
||||||
e: DecodeStatus | undefined,
|
|
||||||
opts: {
|
|
||||||
// busy is "some receiver is mid-QSO", NOT "this one is transmitting".
|
|
||||||
//
|
|
||||||
// The distinction is the whole bug it fixes: with two instances the caller
|
|
||||||
// used to test a single global transmit flag, which belonged to whichever
|
|
||||||
// receiver reported last. So while slice A worked a station, slice B looked
|
|
||||||
// idle and auto-call started another QSO on it — and the moment either
|
|
||||||
// finished it chained straight into the next. One station at a time means
|
|
||||||
// one across ALL receivers, not one per receiver.
|
|
||||||
busy: boolean;
|
|
||||||
calledAt: Map<string, number>;
|
|
||||||
now: number;
|
|
||||||
myCall?: string;
|
|
||||||
},
|
|
||||||
): { call: boolean; reason: string } {
|
|
||||||
const no = (why: string) => ({ call: false, reason: why });
|
|
||||||
if (!s.enabled) return no('off');
|
|
||||||
if (!e) return no('status not resolved yet');
|
|
||||||
const call = (d.call ?? '').trim().toUpperCase();
|
|
||||||
if (!call) return no('no callsign');
|
|
||||||
// Never answer ourselves, however the decode reached us.
|
|
||||||
if (opts.myCall && call === opts.myCall.trim().toUpperCase()) return no('own callsign');
|
|
||||||
// NOT limited to a CQ, deliberately.
|
|
||||||
//
|
|
||||||
// It used to be, on the reasoning that answering a station mid-QSO is calling
|
|
||||||
// over somebody. That reasoning ignored the case the feature exists for: a
|
|
||||||
// DXpedition running a pileup never sends CQ at all — it works caller after
|
|
||||||
// caller — so the rule sat out the one contact auto-call was turned on for. A
|
|
||||||
// new entity on 15 m FT8, decode after decode, and not a single transmission.
|
|
||||||
//
|
|
||||||
// WHEN to transmit is not ours to decide either: the Reply goes to the
|
|
||||||
// decoder, and MSHV starts at once while JTDX waits for a CQ. Two correct
|
|
||||||
// behaviours, both belonging to the program that owns the timing. Here the
|
|
||||||
// question is only whether the station is one the operator wants — the
|
|
||||||
// criteria below answer that, and the cooldown and the busy check keep it from
|
|
||||||
// calling twice.
|
|
||||||
// Not while ANY receiver is mid-QSO — transmitting, or holding a DX call it
|
|
||||||
// has not finished with. Starting a second exchange before the first is done
|
|
||||||
// is what turned this into a machine that called without stopping.
|
|
||||||
if (opts.busy) return no('a QSO is already in progress');
|
|
||||||
const last = opts.calledAt.get(call);
|
|
||||||
if (last !== undefined && opts.now - last < s.cooldownSec * 1000) return no('called recently');
|
|
||||||
|
|
||||||
// The watch list first: an explicitly named station outranks the general
|
|
||||||
// criteria, and may carry conditions of its own.
|
|
||||||
const watched = s.watch.some((p) => matchesWildcard(p, call));
|
|
||||||
if (watched) {
|
|
||||||
if (!anyCriterion(s.watchCriteria)) {
|
|
||||||
// No conditions attached: call it unless it is already worked.
|
|
||||||
return e.worked_call ? no('watched, but already worked') : { call: true, reason: 'watch list' };
|
|
||||||
}
|
|
||||||
return meets(s.watchCriteria, e)
|
|
||||||
? { call: true, reason: 'watch list + criteria' }
|
|
||||||
: no('watched, but no criterion met');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!anyCriterion(s.criteria)) return no('no criteria ticked');
|
|
||||||
return meets(s.criteria, e)
|
|
||||||
? { call: true, reason: 'criteria' }
|
|
||||||
: no('no criterion met');
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// IARU band plans, by region.
|
||||||
|
//
|
||||||
|
// The band maps used to carry one hard-coded table — Region 1's — so a US or
|
||||||
|
// Australian operator saw 40 m stop at 7200 while stations sat spotted at 7250,
|
||||||
|
// and the SSB wash started 25 kHz early. The region is a station-level fact the
|
||||||
|
// operator states once (Settings → General); everything drawing a band edge or
|
||||||
|
// a mode segment reads it from here.
|
||||||
|
//
|
||||||
|
// The tables are deliberately coarse: a band map's wash is CONTEXT, not a
|
||||||
|
// regulatory chart. Only the segments that differ between regions in ways an
|
||||||
|
// operator notices are split out; national fine print (US licence classes,
|
||||||
|
// beacon slots) does not belong in a background tint.
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
|
export type IaruRegion = 1 | 2 | 3;
|
||||||
|
export const KEY_IARU_REGION = 'opslog.iaruRegion';
|
||||||
|
|
||||||
|
export function iaruRegion(): IaruRegion {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(KEY_IARU_REGION);
|
||||||
|
return v === '2' ? 2 : v === '3' ? 3 : 1;
|
||||||
|
} catch { return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setIaruRegion(r: IaruRegion): void {
|
||||||
|
writeUiPref(KEY_IARU_REGION, String(r));
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The band maps capture the plan inside their render, so a change must reach
|
||||||
|
// them the way the distance unit reaches the grids.
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
export function subscribeIaruRegion(fn: () => void): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => { listeners.delete(fn); };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SegMode = 'cw' | 'digi' | 'phone';
|
||||||
|
type Seg = [number, number, SegMode];
|
||||||
|
|
||||||
|
interface Plan {
|
||||||
|
ranges: Record<string, [number, number]>;
|
||||||
|
segments: Record<string, Seg[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Region 1 — Europe / Africa / Middle East / northern Asia. The baseline the
|
||||||
|
// other two override.
|
||||||
|
const R1: Plan = {
|
||||||
|
ranges: {
|
||||||
|
'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],
|
||||||
|
},
|
||||||
|
segments: {
|
||||||
|
'160m': [[1800, 1838, 'cw'], [1838, 1840, 'digi'], [1840, 2000, 'phone']],
|
||||||
|
'80m': [[3500, 3580, 'cw'], [3580, 3600, 'digi'], [3600, 3800, 'phone']],
|
||||||
|
'60m': [[5350, 5450, 'phone']],
|
||||||
|
'40m': [[7000, 7040, 'cw'], [7040, 7100, 'digi'], [7100, 7200, 'phone']],
|
||||||
|
'30m': [[10100, 10130, 'cw'], [10130, 10150, 'digi']],
|
||||||
|
'20m': [[14000, 14070, 'cw'], [14070, 14100, 'digi'], [14100, 14350, 'phone']],
|
||||||
|
'17m': [[18068, 18095, 'cw'], [18095, 18110, 'digi'], [18110, 18168, 'phone']],
|
||||||
|
'15m': [[21000, 21070, 'cw'], [21070, 21150, 'digi'], [21150, 21450, 'phone']],
|
||||||
|
'12m': [[24890, 24915, 'cw'], [24915, 24940, 'digi'], [24940, 24990, 'phone']],
|
||||||
|
'10m': [[28000, 28070, 'cw'], [28070, 28300, 'digi'], [28300, 29700, 'phone']],
|
||||||
|
'6m': [[50000, 50100, 'cw'], [50100, 50500, 'phone']],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Region 2 — the Americas. 80 m runs to 4000 and 40 m to 7300, with phone from
|
||||||
|
// 7125 (the whole point of asking the region: a KP4 ragchew on 7250 must not
|
||||||
|
// hang past the top of the map).
|
||||||
|
const R2: Plan = {
|
||||||
|
ranges: {
|
||||||
|
...R1.ranges,
|
||||||
|
'80m': [3500, 4000], '40m': [7000, 7300],
|
||||||
|
'6m': [50000, 54000], '2m': [144000, 148000], '70cm': [420000, 450000],
|
||||||
|
},
|
||||||
|
segments: {
|
||||||
|
...R1.segments,
|
||||||
|
'80m': [[3500, 3570, 'cw'], [3570, 3600, 'digi'], [3600, 4000, 'phone']],
|
||||||
|
'40m': [[7000, 7040, 'cw'], [7040, 7125, 'digi'], [7125, 7300, 'phone']],
|
||||||
|
'6m': [[50000, 50100, 'cw'], [50100, 54000, 'phone']],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Region 3 — Asia-Pacific. 80 m to 3900; 40 m as Region 1's shape.
|
||||||
|
const R3: Plan = {
|
||||||
|
ranges: {
|
||||||
|
...R1.ranges,
|
||||||
|
'80m': [3500, 3900],
|
||||||
|
'6m': [50000, 54000], '2m': [144000, 148000], '70cm': [430000, 450000],
|
||||||
|
},
|
||||||
|
segments: {
|
||||||
|
...R1.segments,
|
||||||
|
'80m': [[3500, 3535, 'cw'], [3535, 3600, 'digi'], [3600, 3900, 'phone']],
|
||||||
|
'6m': [[50000, 50100, 'cw'], [50100, 54000, 'phone']],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function plan(): Plan {
|
||||||
|
const r = iaruRegion();
|
||||||
|
return r === 2 ? R2 : r === 3 ? R3 : R1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bandRange(band: string): [number, number] | undefined {
|
||||||
|
return plan().ranges[band];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bandSegments(band: string): Seg[] {
|
||||||
|
return plan().segments[band] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' when it falls
|
||||||
|
// outside every known allocation.
|
||||||
|
//
|
||||||
|
// Lives here rather than in a panel because more than one place has to answer
|
||||||
|
// the same question the same way: the entry form retunes on a typed frequency,
|
||||||
|
// and the QSO editor has to keep band and frequency agreeing when one of them
|
||||||
|
// is corrected. Kept in step with BandFromHz on the Go side.
|
||||||
|
export function bandForMHz(mhz: number): string {
|
||||||
|
if (!mhz || isNaN(mhz)) return '';
|
||||||
|
const plan: [number, number, string][] = [
|
||||||
|
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
|
||||||
|
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
|
||||||
|
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
|
||||||
|
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [902, 928, '33cm'], [1240, 1300, '23cm'],
|
||||||
|
// Microwave, ADIF 3.1.7 ranges.
|
||||||
|
[2300, 2450, '13cm'], [3300, 3500, '9cm'], [5650, 5925, '6cm'], [10000, 10500, '3cm'],
|
||||||
|
[24000, 24250, '1.25cm'], [47000, 47200, '6mm'], [75500, 81000, '4mm'],
|
||||||
|
[119980, 123000, '2.5mm'], [134000, 149000, '2mm'], [241000, 250000, '1mm'],
|
||||||
|
];
|
||||||
|
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// What a decoding program is CALLED, as against what it calls itself.
|
||||||
|
//
|
||||||
|
// Every WSJT-X-family packet carries an "id" naming the sending program, and
|
||||||
|
// OpsLog shows it wherever a receiver has to be told apart from another. Most
|
||||||
|
// of them send the name on the box: "WSJT-X", "JTDX", "MSHV".
|
||||||
|
//
|
||||||
|
// Nexus does not. It announces itself as "Tempo" — the name of the engine
|
||||||
|
// inside it — so an operator running Nexus saw a program on their screen they
|
||||||
|
// have never heard of, and had to work out that it was theirs.
|
||||||
|
//
|
||||||
|
// Only the LABEL is translated. The id stays the routing key everywhere else:
|
||||||
|
// a Reply, a Halt and the auto-call's own bookkeeping are matched against what
|
||||||
|
// the program sent, and renaming that would send them to nobody.
|
||||||
|
const NAMES: Record<string, string> = {
|
||||||
|
TEMPO: 'Nexus',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function decoderName(id?: string): string {
|
||||||
|
const raw = (id ?? '').trim();
|
||||||
|
if (!raw) return '';
|
||||||
|
// Matched on the leading word: some programs append a version or an instance
|
||||||
|
// number ("WSJT-X - 2", "Tempo 1.4"), and the name is the part before it.
|
||||||
|
const head = raw.split(/[\s\-–—]+/)[0].toUpperCase();
|
||||||
|
return NAMES[head] ?? raw;
|
||||||
|
}
|
||||||
+226
-84
File diff suppressed because one or more lines are too long
@@ -172,6 +172,50 @@ export function greatCirclePoints(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitAtAntimeridian cuts a continuous (unwrapped) path into the pieces that
|
||||||
|
// fit on a map showing ONE world, each piece with longitudes back inside ±180.
|
||||||
|
//
|
||||||
|
// greatCirclePoints deliberately lets longitude run past ±180 so the polyline
|
||||||
|
// stays smooth. That is right for a map with repeating world copies, and wrong
|
||||||
|
// for one without: an arc from Australia to South America came out at 190°,
|
||||||
|
// 210°, 250° — drawn into the empty space off the right-hand edge, ending
|
||||||
|
// nowhere, while its own end marker sat correctly on the far left. From VK,
|
||||||
|
// where most paths cross the antimeridian, that was most of the map's arcs.
|
||||||
|
//
|
||||||
|
// Each crossing ends one piece at exactly ±180 and starts the next at the
|
||||||
|
// opposite edge, at the SAME latitude, so the line leaves one side of the map
|
||||||
|
// and re-enters the other at the height it left. Leaflet takes the result as a
|
||||||
|
// multi-polyline, so one path is still one layer.
|
||||||
|
export function splitAtAntimeridian(pts: [number, number][]): [number, number][][] {
|
||||||
|
if (pts.length === 0) return [];
|
||||||
|
// Which copy of the world a longitude belongs to: 0 is the map's own.
|
||||||
|
const world = (lon: number) => Math.floor((lon + 180) / 360);
|
||||||
|
const norm = (lon: number) => lon - 360 * world(lon);
|
||||||
|
const out: [number, number][][] = [];
|
||||||
|
let cur: [number, number][] = [];
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
const [lat, lon] = pts[i];
|
||||||
|
if (i > 0) {
|
||||||
|
const [pLat, pLon] = pts[i - 1];
|
||||||
|
const wPrev = world(pLon), wCur = world(lon);
|
||||||
|
if (wPrev !== wCur) {
|
||||||
|
const east = wCur > wPrev;
|
||||||
|
// The meridian actually crossed, in unwrapped degrees.
|
||||||
|
const edge = 180 + 360 * Math.min(wPrev, wCur);
|
||||||
|
const f = (edge - pLon) / (lon - pLon);
|
||||||
|
const edgeLat = pLat + f * (lat - pLat);
|
||||||
|
cur.push([edgeLat, east ? 180 : -180]);
|
||||||
|
out.push(cur);
|
||||||
|
cur = [[edgeLat, east ? -180 : 180]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cur.push([lat, norm(lon)]);
|
||||||
|
}
|
||||||
|
if (cur.length > 1) out.push(cur);
|
||||||
|
else if (cur.length === 1 && out.length === 0) out.push(cur);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function toRad(d: number): number { return (d * Math.PI) / 180; }
|
function toRad(d: number): number { return (d * Math.PI) / 180; }
|
||||||
function toDeg(r: number): number { return (r * 180) / Math.PI; }
|
function toDeg(r: number): number { return (r * 180) / Math.PI; }
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ export type MatrixColors = {
|
|||||||
entity_worked: string;
|
entity_worked: string;
|
||||||
not_worked: string;
|
not_worked: string;
|
||||||
current_entry: string;
|
current_entry: string;
|
||||||
|
mark_worked: string;
|
||||||
|
mark_confirmed: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The six settings fields and the CSS custom property each one drives. Also the
|
// The settings fields and the CSS custom property each one drives. Also the
|
||||||
// display order — the same order the legend under the matrix reads in, so the
|
// display order — the same order the legend under the matrix reads in, so the
|
||||||
// settings panel and the grid can never disagree about which green is which.
|
// settings panel and the grid can never disagree about which green is which.
|
||||||
export const MATRIX_VARS: { key: keyof Omit<MatrixColors, 'enabled'>; cssVar: string; label: string }[] = [
|
export const MATRIX_VARS: { key: keyof Omit<MatrixColors, 'enabled'>; cssVar: string; label: string }[] = [
|
||||||
@@ -26,12 +28,15 @@ export const MATRIX_VARS: { key: keyof Omit<MatrixColors, 'enabled'>; cssVar: st
|
|||||||
{ key: 'entity_worked', cssVar: '--mx-dx-work', label: 'mx.dxWork' },
|
{ key: 'entity_worked', cssVar: '--mx-dx-work', label: 'mx.dxWork' },
|
||||||
{ key: 'not_worked', cssVar: '--mx-none', label: 'mx.none' },
|
{ key: 'not_worked', cssVar: '--mx-none', label: 'mx.none' },
|
||||||
{ key: 'current_entry', cssVar: '--mx-cur', label: 'mx.current' },
|
{ key: 'current_entry', cssVar: '--mx-cur', label: 'mx.current' },
|
||||||
|
{ key: 'mark_worked', cssVar: '--mx-mark-work', label: 'mx.markWork' },
|
||||||
|
{ key: 'mark_confirmed', cssVar: '--mx-mark-conf', label: 'mx.markConf' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const emptyMatrixColors = (): MatrixColors => ({
|
export const emptyMatrixColors = (): MatrixColors => ({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
call_confirmed: '', call_worked: '', entity_confirmed: '',
|
call_confirmed: '', call_worked: '', entity_confirmed: '',
|
||||||
entity_worked: '', not_worked: '', current_entry: '',
|
entity_worked: '', not_worked: '', current_entry: '',
|
||||||
|
mark_worked: '', mark_confirmed: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// applyMatrixColors stamps (or clears) the overrides on <html>. Safe to call as
|
// applyMatrixColors stamps (or clears) the overrides on <html>. Safe to call as
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Which rotor dial to draw.
|
||||||
|
//
|
||||||
|
// Two compasses ship: the current one (night map, square scale, mouse pointer,
|
||||||
|
// target marker) and the original small light-map dial. Neither is more correct
|
||||||
|
// than the other — one operator reads the big map at a glance, another wants the
|
||||||
|
// compact dial that was there before — so it is a preference, not a migration.
|
||||||
|
//
|
||||||
|
// A UI preference rather than a settings-database key: it decides what a widget
|
||||||
|
// looks like, nothing is transmitted from it, and it belongs to the screen it is
|
||||||
|
// read on. It is portable all the same (see lib/uiPref), so a copied folder
|
||||||
|
// keeps the dial its owner chose.
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
|
export const KEY_ROTOR_STYLE = 'opslog.rotorStyle';
|
||||||
|
|
||||||
|
export type RotorStyle = 'modern' | 'classic';
|
||||||
|
|
||||||
|
export function rotorStyle(): RotorStyle {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(KEY_ROTOR_STYLE) === 'classic' ? 'classic' : 'modern';
|
||||||
|
} catch {
|
||||||
|
return 'modern';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same shape as the distance unit: the compasses are rendered from two call
|
||||||
|
// sites, and a preference changed in Settings has to reach both without either
|
||||||
|
// of them polling for it.
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function setRotorStyle(style: RotorStyle): void {
|
||||||
|
writeUiPref(KEY_ROTOR_STYLE, style);
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeRotorStyle(fn: () => void): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => { listeners.delete(fn); };
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@ export function cleanSpotter(s: string): string {
|
|||||||
// alone instead of guessing wrong.
|
// alone instead of guessing wrong.
|
||||||
export function inferSpotMode(comment: string, freqHz: number): string {
|
export function inferSpotMode(comment: string, freqHz: number): string {
|
||||||
const c = (comment || '').toUpperCase();
|
const c = (comment || '').toUpperCase();
|
||||||
|
// SuperFox and Fox/Hound are FT8 — they are WSJT-X's DXpedition transmit
|
||||||
|
// modes, not modes of their own. A spot commented "super fox" fell through to
|
||||||
|
// the band plan and came out DATA, and that verdict is not cosmetic: the
|
||||||
|
// band+mode status is computed from this answer, so a ZD8 on 21.071 read as a
|
||||||
|
// new DATA slot rather than the new FT8 one it is.
|
||||||
|
if (/\bSUPER\s*FOX\b|\bSFOX\b|\bFOX\s*\/?\s*HOUND\b|\bF\/H\b/.test(c)) return 'FT8';
|
||||||
if (/\bFT8\b/.test(c)) return 'FT8';
|
if (/\bFT8\b/.test(c)) return 'FT8';
|
||||||
if (/\bFT4\b/.test(c)) return 'FT4';
|
if (/\bFT4\b/.test(c)) return 'FT4';
|
||||||
if (/\bJS8\b/.test(c)) return 'JS8';
|
if (/\bJS8\b/.test(c)) return 'JS8';
|
||||||
@@ -36,17 +42,17 @@ export function inferSpotMode(comment: string, freqHz: number): string {
|
|||||||
type Seg = [number, number, string];
|
type Seg = [number, number, string];
|
||||||
const segs: Seg[] = [
|
const segs: Seg[] = [
|
||||||
[1.8, 1.838, 'CW'], [1.838, 1.84, 'FT8'], [1.84, 2.0, 'SSB'],
|
[1.8, 1.838, 'CW'], [1.838, 1.84, 'FT8'], [1.84, 2.0, 'SSB'],
|
||||||
[3.5, 3.58, 'CW'], [3.573, 3.576, 'FT8'], [3.58, 3.6, 'DATA'], [3.6, 4.0, 'SSB'],
|
[3.573, 3.575, 'FT8'], [3.575, 3.578, 'FT4'], [3.5, 3.58, 'CW'], [3.58, 3.6, 'DATA'], [3.6, 4.0, 'SSB'],
|
||||||
[5.3, 5.5, 'SSB'],
|
[5.357, 5.36, 'FT8'], [5.3, 5.5, 'SSB'],
|
||||||
[7.0, 7.04, 'CW'], [7.074, 7.077, 'FT8'], [7.0475, 7.0485, 'FT4'],
|
[7.0, 7.04, 'CW'], [7.074, 7.077, 'FT8'], [7.0475, 7.0485, 'FT4'],
|
||||||
[7.04, 7.1, 'DATA'], [7.1, 7.3, 'SSB'],
|
[7.04, 7.1, 'DATA'], [7.1, 7.3, 'SSB'],
|
||||||
[10.1, 10.13, 'CW'], [10.13, 10.15, 'DATA'],
|
[10.136, 10.139, 'FT8'], [10.14, 10.143, 'FT4'], [10.1, 10.13, 'CW'], [10.13, 10.15, 'DATA'],
|
||||||
[14.0, 14.07, 'CW'], [14.074, 14.077, 'FT8'], [14.08, 14.0815, 'FT4'],
|
[14.0, 14.07, 'CW'], [14.074, 14.077, 'FT8'], [14.08, 14.0815, 'FT4'],
|
||||||
[14.07, 14.1, 'DATA'], [14.1, 14.35, 'SSB'],
|
[14.07, 14.1, 'DATA'], [14.1, 14.35, 'SSB'],
|
||||||
[18.068, 18.095, 'CW'], [18.1, 18.103, 'FT8'], [18.095, 18.11, 'DATA'], [18.11, 18.168, 'SSB'],
|
[18.1, 18.103, 'FT8'], [18.104, 18.107, 'FT4'], [18.068, 18.095, 'CW'], [18.095, 18.11, 'DATA'], [18.11, 18.168, 'SSB'],
|
||||||
[21.0, 21.07, 'CW'], [21.074, 21.077, 'FT8'], [21.14, 21.143, 'FT4'],
|
[21.0, 21.07, 'CW'], [21.074, 21.077, 'FT8'], [21.14, 21.143, 'FT4'],
|
||||||
[21.07, 21.15, 'DATA'], [21.15, 21.45, 'SSB'],
|
[21.07, 21.15, 'DATA'], [21.15, 21.45, 'SSB'],
|
||||||
[24.89, 24.915, 'CW'], [24.915, 24.917, 'FT8'], [24.915, 24.94, 'DATA'], [24.94, 24.99, 'SSB'],
|
[24.915, 24.918, 'FT8'], [24.919, 24.922, 'FT4'], [24.89, 24.915, 'CW'], [24.915, 24.94, 'DATA'], [24.94, 24.99, 'SSB'],
|
||||||
[28.0, 28.07, 'CW'], [28.074, 28.077, 'FT8'], [28.18, 28.183, 'FT4'],
|
[28.0, 28.07, 'CW'], [28.074, 28.077, 'FT8'], [28.18, 28.183, 'FT4'],
|
||||||
[28.07, 28.3, 'DATA'], [28.3, 29.7, 'SSB'],
|
[28.07, 28.3, 'DATA'], [28.3, 29.7, 'SSB'],
|
||||||
[50.0, 50.1, 'CW'], [50.313, 50.316, 'FT8'], [50.318, 50.321, 'FT4'],
|
[50.0, 50.1, 'CW'], [50.313, 50.316, 'FT8'], [50.318, 50.321, 'FT4'],
|
||||||
|
|||||||
@@ -11,7 +11,63 @@
|
|||||||
//
|
//
|
||||||
// They compose deliberately: mute what is done, light up what is not.
|
// They compose deliberately: mute what is done, light up what is not.
|
||||||
|
|
||||||
export type SpotDisplayOptions = { muteWorked: boolean; slotHighlight: boolean };
|
export type SpotDisplayOptions = {
|
||||||
|
muteWorked: boolean; slotHighlight: boolean;
|
||||||
|
// Chase switches (Settings → DX Cluster), ON by default. An operator who does
|
||||||
|
// not chase parks does not want NEW POTA shouting from every activator spot:
|
||||||
|
// off, the marker is withdrawn at the display layer — a "new band + new POTA"
|
||||||
|
// spot simply reads NEW BAND — and the reference column goes quiet. The facts
|
||||||
|
// keep being computed; only the telling stops, so ticking the box back on
|
||||||
|
// needs no rescan.
|
||||||
|
chasePota: boolean; chaseSota: boolean;
|
||||||
|
chaseCounty: boolean; chasePfx: boolean; chaseGrid: boolean; chaseState: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// chasePota/chaseSota read the switches directly — for the places that show a
|
||||||
|
// REFERENCE rather than a status (the POTA and SOTA columns).
|
||||||
|
export function chasePota(): boolean {
|
||||||
|
try { return localStorage.getItem('opslog.chasePota') !== '0'; } catch { return true; }
|
||||||
|
}
|
||||||
|
export function chaseSota(): boolean {
|
||||||
|
try { return localStorage.getItem('opslog.chaseSota') !== '0'; } catch { return true; }
|
||||||
|
}
|
||||||
|
export function chaseCounty(): boolean {
|
||||||
|
try { return localStorage.getItem('opslog.chaseCounty') !== '0'; } catch { return true; }
|
||||||
|
}
|
||||||
|
export function chasePfx(): boolean {
|
||||||
|
try { return localStorage.getItem('opslog.chasePfx') !== '0'; } catch { return true; }
|
||||||
|
}
|
||||||
|
export function chaseState(): boolean {
|
||||||
|
try { return localStorage.getItem('opslog.chaseState') !== '0'; } catch { return true; }
|
||||||
|
}
|
||||||
|
// chaseGrid mirrors the backend "chase new grids" setting (Settings writes the
|
||||||
|
// mirror on load and on toggle) so the display layer can gate NEW GRID without
|
||||||
|
// an async round-trip per row.
|
||||||
|
export function chaseGrid(): boolean {
|
||||||
|
try { return localStorage.getItem('opslog.chaseGrids') !== '0'; } catch { return true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// chaseAllows answers "does this operator chase this kind of thing at all?"
|
||||||
|
// for the ORTHOGONAL markers — park, square, prefix, county.
|
||||||
|
//
|
||||||
|
// The switches were written for the cluster and stayed there, so an operator
|
||||||
|
// who does not chase parks still met NEW POTA in the decode list and in Chase
|
||||||
|
// new: the same badge, withdrawn on one screen and shouting on the next. The
|
||||||
|
// setting is about what the operator hunts, not about which panel is open.
|
||||||
|
//
|
||||||
|
// The keys are both the status-entry field names (new_pota…) and the shorter
|
||||||
|
// category names the panels filter with (pota…), so one call serves both.
|
||||||
|
// Anything without a switch of its own — a new US state — is always allowed.
|
||||||
|
export function chaseAllows(key: string): boolean {
|
||||||
|
switch (key) {
|
||||||
|
case 'new_pota': case 'pota': return chasePota();
|
||||||
|
case 'new_grid': case 'grid': return chaseGrid();
|
||||||
|
case 'new_pfx': case 'pfx': return chasePfx();
|
||||||
|
case 'new_county': case 'cty': return chaseCounty();
|
||||||
|
case 'new_state': case 'state': return chaseState();
|
||||||
|
default: return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Both options are withdrawn from the filter panel for now. The machinery below
|
// Both options are withdrawn from the filter panel for now. The machinery below
|
||||||
// is deliberately kept whole — it is correct and hard-won — so putting the two
|
// is deliberately kept whole — it is correct and hard-won — so putting the two
|
||||||
@@ -23,14 +79,21 @@ export type SpotDisplayOptions = { muteWorked: boolean; slotHighlight: boolean }
|
|||||||
export const SPOT_DISPLAY_OPTIONS_EXPOSED = false;
|
export const SPOT_DISPLAY_OPTIONS_EXPOSED = false;
|
||||||
|
|
||||||
export function readSpotDisplayOptions(): SpotDisplayOptions {
|
export function readSpotDisplayOptions(): SpotDisplayOptions {
|
||||||
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) return { muteWorked: false, slotHighlight: false };
|
// The EXPOSED flag only withdraws the two original switches; the chase
|
||||||
|
// switches are live regardless.
|
||||||
|
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) {
|
||||||
|
return { muteWorked: false, slotHighlight: false, chasePota: chasePota(), chaseSota: chaseSota(), chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid(), chaseState: chaseState() };
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
|
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
|
||||||
slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1',
|
slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1',
|
||||||
|
chasePota: chasePota(), chaseSota: chaseSota(),
|
||||||
|
chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid(),
|
||||||
|
chaseState: chaseState(),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { muteWorked: false, slotHighlight: false };
|
return { muteWorked: false, slotHighlight: false, chasePota: true, chaseSota: true, chaseCounty: true, chasePfx: true, chaseGrid: true, chaseState: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +105,7 @@ type Entry = {
|
|||||||
new_pota?: boolean;
|
new_pota?: boolean;
|
||||||
new_pfx?: boolean;
|
new_pfx?: boolean;
|
||||||
new_grid?: boolean;
|
new_grid?: boolean;
|
||||||
|
new_state?: boolean;
|
||||||
} | undefined;
|
} | undefined;
|
||||||
|
|
||||||
// applySpotDisplay rewrites a status entry per the options, so every consumer —
|
// applySpotDisplay rewrites a status entry per the options, so every consumer —
|
||||||
@@ -72,6 +136,23 @@ export function applySpotDisplay<T extends Entry>(s: T, o: SpotDisplayOptions):
|
|||||||
if (o.muteWorked) {
|
if (o.muteWorked) {
|
||||||
e = { ...e, worked_call: false } as NonNullable<T>;
|
e = { ...e, worked_call: false } as NonNullable<T>;
|
||||||
}
|
}
|
||||||
|
if (!o.chasePota && e.new_pota) {
|
||||||
|
e = { ...e, new_pota: false } as NonNullable<T>;
|
||||||
|
}
|
||||||
|
// Same withdrawal for the other extra markers: the facts keep being
|
||||||
|
// computed, only the telling stops — re-ticking a box needs no rescan.
|
||||||
|
if (!o.chaseCounty && e.new_county) {
|
||||||
|
e = { ...e, new_county: false } as NonNullable<T>;
|
||||||
|
}
|
||||||
|
if (!o.chasePfx && e.new_pfx) {
|
||||||
|
e = { ...e, new_pfx: false } as NonNullable<T>;
|
||||||
|
}
|
||||||
|
if (!o.chaseGrid && e.new_grid) {
|
||||||
|
e = { ...e, new_grid: false } as NonNullable<T>;
|
||||||
|
}
|
||||||
|
if (!o.chaseState && e.new_state) {
|
||||||
|
e = { ...e, new_state: false } as NonNullable<T>;
|
||||||
|
}
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
// grid magenta — the last hue in the categorical set that is not already
|
// grid magenta — the last hue in the categorical set that is not already
|
||||||
// spoken for here and does not read as a status; a grid is
|
// spoken for here and does not read as a status; a grid is
|
||||||
// never urgent the way a new entity is
|
// never urgent the way a new entity is
|
||||||
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call' | 'new_grid';
|
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call' | 'new_grid' | 'new_state';
|
||||||
|
|
||||||
export type SpotMarker = {
|
export type SpotMarker = {
|
||||||
key: SpotMarkerKey;
|
key: SpotMarkerKey;
|
||||||
@@ -32,6 +32,7 @@ export const SPOT_MARKERS: SpotMarker[] = [
|
|||||||
{ key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' },
|
{ key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' },
|
||||||
{ key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' },
|
{ key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' },
|
||||||
{ key: 'new_grid', colour: 'var(--chart-7)', labelKey: 'clg2.newGrid' },
|
{ key: 'new_grid', colour: 'var(--chart-7)', labelKey: 'clg2.newGrid' },
|
||||||
|
{ key: 'new_state', colour: 'var(--chart-3)', labelKey: 'clg2.newState' },
|
||||||
{ key: 'worked_call', colour: 'var(--info)', labelKey: 'clg2.wkdCall' },
|
{ key: 'worked_call', colour: 'var(--info)', labelKey: 'clg2.wkdCall' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import { GetUIPref } from '../../wailsjs/go/main/App';
|
|||||||
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
||||||
// travels with the data/ folder like the language).
|
// travels with the data/ folder like the language).
|
||||||
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'light-nordic' | 'sahara'
|
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'light-nordic' | 'sahara'
|
||||||
| 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'high-contrast';
|
| 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'dxhunter' | 'dxhunter-orange' | 'high-contrast';
|
||||||
|
|
||||||
// Selectable, concrete themes (excludes 'auto') in display order: lights first,
|
// Selectable, concrete themes (excludes 'auto') in display order: lights first,
|
||||||
// then darks, with high-contrast last — it is an accessibility choice, not a
|
// then darks, with high-contrast last — it is an accessibility choice, not a
|
||||||
// taste one, and listing it among the moods buries it.
|
// taste one, and listing it among the moods buries it.
|
||||||
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
||||||
'light-warm', 'light-cool', 'light-sage', 'light-nordic', 'sahara',
|
'light-warm', 'light-cool', 'light-sage', 'light-nordic', 'sahara',
|
||||||
'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum',
|
'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum', 'dxhunter', 'dxhunter-orange',
|
||||||
'high-contrast',
|
'high-contrast',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||||
|
'opslog.distanceMiles', // distances shown in statute miles rather than km
|
||||||
|
'opslog.iaruRegion', // IARU region (1/2/3) — band edges and segments on the band maps
|
||||||
|
'opslog.chasePota', // show POTA references and the NEW POTA marker on spots
|
||||||
|
'opslog.chaseSota', // show SOTA references on spots
|
||||||
|
// The other chase switches. In the DB as well as locally because AUTO-CALL
|
||||||
|
// reads them: what the operator does not hunt is not something to transmit
|
||||||
|
// for, and the backend cannot see localStorage.
|
||||||
|
'opslog.chasePfx', 'opslog.chaseCounty', 'opslog.chaseState', 'opslog.chaseGrids',
|
||||||
'opslog.activeTab', // last selected tab
|
'opslog.activeTab', // last selected tab
|
||||||
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
|
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
|
||||||
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
|
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Distance units.
|
||||||
|
//
|
||||||
|
// Everything is COMPUTED in kilometres — the great-circle maths, the backend's
|
||||||
|
// distance_km on a spot, the map's path lengths — and converted once, here, at
|
||||||
|
// display time. Storing miles anywhere would mean two sources of truth for the
|
||||||
|
// same number and a rounding error that grows with every hop.
|
||||||
|
//
|
||||||
|
// The preference is portable (see lib/uiPref): an operator who works in miles
|
||||||
|
// works in miles on every machine they copy their folder to.
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
|
export const KEY_MILES = 'opslog.distanceMiles';
|
||||||
|
const KM_PER_MILE = 1.609344; // statute miles, the ones a US licence is used in
|
||||||
|
|
||||||
|
export function useMiles(): boolean {
|
||||||
|
try { return localStorage.getItem(KEY_MILES) === '1'; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUseMiles(on: boolean): void {
|
||||||
|
writeUiPref(KEY_MILES, on ? '1' : '0');
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribeDistanceUnit notifies on a change. The grids capture the unit inside
|
||||||
|
// their column definitions (header text and formatter both), so without this a
|
||||||
|
// toggle would only show up on the next language change or restart.
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
export function subscribeDistanceUnit(fn: () => void): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => { listeners.delete(fn); };
|
||||||
|
}
|
||||||
|
|
||||||
|
// distanceValue converts a distance in km to the operator's unit, rounded to a
|
||||||
|
// whole unit — the precision the inputs actually justify (a 4-character grid is
|
||||||
|
// a square tens of kilometres wide).
|
||||||
|
export function distanceValue(km: number): number {
|
||||||
|
if (!isFinite(km)) return 0;
|
||||||
|
return Math.round(useMiles() ? km / KM_PER_MILE : km);
|
||||||
|
}
|
||||||
|
|
||||||
|
// distanceUnit is the short label: "km" or "mi".
|
||||||
|
export function distanceUnit(): string {
|
||||||
|
return useMiles() ? 'mi' : 'km';
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDistance is value + unit, thousands-separated: "12 345 km".
|
||||||
|
export function formatDistance(km: number): string {
|
||||||
|
return `${distanceValue(km).toLocaleString()} ${distanceUnit()}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// What the watch list is HEARING, and what of it is still needed.
|
||||||
|
//
|
||||||
|
// Two places ask the same question — the Watchlist tab and the docked widget —
|
||||||
|
// and the answer involves a debounced round trip to the logbook per visible
|
||||||
|
// slot. Written twice it would be two definitions of "needed" drifting apart,
|
||||||
|
// and two bursts of the same query on every spot; written here it is one.
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { ClusterSpot } from '@/components/ClusterGrid';
|
||||||
|
import { inferSpotMode } from '@/lib/spot';
|
||||||
|
import { WatchlistWorkedSlots } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
export interface WLEntry {
|
||||||
|
callsign: string; lastSeenStr: string; addedAt: string; spotCount: number;
|
||||||
|
isContest: boolean; notify: boolean;
|
||||||
|
isExpedition: boolean; clubLogQSOs24h: number; clubLogTotalQSOs: number;
|
||||||
|
clubLogHasOQRS: boolean; clubLogLiveStream: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A spot is ON AIR while its last sighting is this fresh.
|
||||||
|
export const ON_AIR_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
// Exact unless the entry carries a trailing * — the same rule the backend's
|
||||||
|
// Match applies to the live stream, mirrored so the list and the alerts can
|
||||||
|
// never disagree about what an entry covers.
|
||||||
|
export function matchesEntry(call: string, pattern: string): boolean {
|
||||||
|
const c = call.toUpperCase();
|
||||||
|
if (pattern.endsWith('*')) {
|
||||||
|
const p = pattern.slice(0, -1);
|
||||||
|
return p !== '' && c.startsWith(p);
|
||||||
|
}
|
||||||
|
return c === pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the cluster's verdict is worth saying, and in what colour.
|
||||||
|
//
|
||||||
|
// The order of severity is the one the band map and Chase new use: a new entity
|
||||||
|
// first, then the band and mode inside it. Shared because the tab and the docked
|
||||||
|
// widget must not label the same spot differently — an operator reads one of
|
||||||
|
// them to decide whether to leave what they are doing.
|
||||||
|
export const NEW_BADGES: Record<string, { key: string; colour: string }> = {
|
||||||
|
'new': { key: 'wl.newDxcc', colour: 'var(--danger)' },
|
||||||
|
'new-band-mode': { key: 'clg2.newBandMode', colour: 'var(--danger)' },
|
||||||
|
'new-band': { key: 'clg2.newBand', colour: 'var(--warning)' },
|
||||||
|
'new-mode': { key: 'clg2.newMode', colour: 'var(--caution)' },
|
||||||
|
'new-slot': { key: 'clg2.newSlot', colour: '#5AC8FA' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function newBadge(status?: string): { key: string; colour: string } | null {
|
||||||
|
return (status && NEW_BADGES[status]) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchlistSpots {
|
||||||
|
// The raw verdicts, exposed for dependency arrays: it changes only when an
|
||||||
|
// answer arrives, where the closures below are new on every render.
|
||||||
|
worked: Record<string, boolean>;
|
||||||
|
// The spots each entry covers, deduplicated by band+mode.
|
||||||
|
spotsFor: Map<string, ClusterSpot[]>;
|
||||||
|
// Worked on this exact slot (and, for a contest entry, today).
|
||||||
|
workedFor: (e: WLEntry, s: ClusterSpot) => boolean;
|
||||||
|
// Whether the logbook has actually answered for this pair yet. A spot whose
|
||||||
|
// verdict has not come back is NOT drawn: showing it as needed and
|
||||||
|
// withdrawing it half a second later made the list twitch on every burst,
|
||||||
|
// and nobody needs a spot 400 ms early — they need it settled.
|
||||||
|
settled: (e: WLEntry, s: ClusterSpot) => boolean;
|
||||||
|
onAir: (e: WLEntry) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWatchlistSpots(entries: WLEntry[], spots: ClusterSpot[]): WatchlistSpots {
|
||||||
|
const [worked, setWorked] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const spotsFor = useMemo(() => {
|
||||||
|
const map = new Map<string, ClusterSpot[]>();
|
||||||
|
for (const e of entries) map.set(e.callsign, []);
|
||||||
|
for (const s of spots) {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (matchesEntry(s.dx_call ?? '', e.callsign)) { map.get(e.callsign)!.push(s); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [k, list] of map) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
map.set(k, list.filter((s) => {
|
||||||
|
const key = `${(s.band ?? '')}|${inferSpotMode(s.comment ?? '', s.freq_hz)}|${s.dx_call}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [spots, entries]);
|
||||||
|
|
||||||
|
// Debounced: a spot burst must cost one round trip, not one per spot.
|
||||||
|
const queryTimer = useRef<number | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
if (queryTimer.current) window.clearTimeout(queryTimer.current);
|
||||||
|
queryTimer.current = window.setTimeout(async () => {
|
||||||
|
const queries: { call: string; band: string; mode: string; contest: boolean }[] = [];
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (const e of entries) {
|
||||||
|
for (const s of spotsFor.get(e.callsign) ?? []) {
|
||||||
|
const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
|
||||||
|
queries.push({ call: s.dx_call, band: s.band ?? '', mode, contest: e.isContest });
|
||||||
|
keys.push(`${s.dx_call}|${s.band ?? ''}|${mode}|${e.isContest ? 1 : 0}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queries.length === 0) { setWorked({}); return; }
|
||||||
|
try {
|
||||||
|
const res: boolean[] = (await WatchlistWorkedSlots(queries as any)) ?? [];
|
||||||
|
// MERGED, not replaced: replacing made every already-answered key
|
||||||
|
// momentarily unknown on each refresh, which re-hid settled lines.
|
||||||
|
setWorked((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
keys.forEach((k, i) => { next[k] = !!res[i]; });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch { /* the badges just stay conservative */ }
|
||||||
|
}, 150) as unknown as number;
|
||||||
|
return () => { if (queryTimer.current) window.clearTimeout(queryTimer.current); };
|
||||||
|
}, [spotsFor, entries]);
|
||||||
|
|
||||||
|
const wkey = (e: WLEntry, s: ClusterSpot) =>
|
||||||
|
`${s.dx_call}|${s.band ?? ''}|${inferSpotMode(s.comment ?? '', s.freq_hz) || ''}|${e.isContest ? 1 : 0}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
worked,
|
||||||
|
spotsFor,
|
||||||
|
workedFor: (e, s) => worked[wkey(e, s)] ?? false,
|
||||||
|
settled: (e, s) => wkey(e, s) in worked,
|
||||||
|
onAir: (e) => (spotsFor.get(e.callsign) ?? []).some((s) => {
|
||||||
|
const ts = Date.parse(String((s as any).received_at ?? ''));
|
||||||
|
return ts > 0 && Date.now() - ts < ON_AIR_MS;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
+169
-3
@@ -91,6 +91,11 @@
|
|||||||
be recoloured on its own without dragging every other warning in the app
|
be recoloured on its own without dragging every other warning in the app
|
||||||
with it (Appearance → matrix colours). */
|
with it (Appearance → matrix colours). */
|
||||||
--mx-cur: var(--warning);
|
--mx-cur: var(--warning);
|
||||||
|
/* The "worked with this callsign" dot. Declared ONCE, like --mx-cur: it is
|
||||||
|
drawn over every one of the five cell colours, so it follows the theme's own
|
||||||
|
foreground/background pair rather than a per-theme colour of its own. */
|
||||||
|
--mx-mark-work: var(--foreground);
|
||||||
|
--mx-mark-conf: var(--foreground);
|
||||||
|
|
||||||
--scrollbar-thumb: #b8a880;
|
--scrollbar-thumb: #b8a880;
|
||||||
--scrollbar-thumb-hover: #968455;
|
--scrollbar-thumb-hover: #968455;
|
||||||
@@ -569,8 +574,8 @@
|
|||||||
"entity confirmed" cell would read as a button. */
|
"entity confirmed" cell would read as a button. */
|
||||||
--mx-call-conf: #22c55e;
|
--mx-call-conf: #22c55e;
|
||||||
--mx-call-work: #2c7a52;
|
--mx-call-work: #2c7a52;
|
||||||
--mx-dx-conf: #22d3ee;
|
--mx-dx-conf: #a78bfa;
|
||||||
--mx-dx-work: #1b6b7c;
|
--mx-dx-work: #5b4a9e;
|
||||||
--mx-none: #2c2f4d;
|
--mx-none: #2c2f4d;
|
||||||
|
|
||||||
--scrollbar-thumb: #383c63;
|
--scrollbar-thumb: #383c63;
|
||||||
@@ -857,6 +862,156 @@
|
|||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 13: DXHunter — its own slate and blue -----------------------
|
||||||
|
Ported so an operator running both side by side does not switch between two
|
||||||
|
colour worlds every time they look up. It is Tailwind's slate scale, which
|
||||||
|
is what DXHunter is built on: page at slate-900, panels at slate-800, rules
|
||||||
|
at slate-700, and blue-500 for the accent — counted across its sources, not
|
||||||
|
guessed from one panel: blue is 132 uses to violet's 25, and the violet is
|
||||||
|
the PSK Reporter panel alone. Active tab, focus border, primary button: all
|
||||||
|
blue-500. The status colours are DXHunter's too — emerald for good, amber
|
||||||
|
for attention, cyan for information, red for trouble — so a green number
|
||||||
|
means the same thing in both windows. */
|
||||||
|
[data-theme="dxhunter"] {
|
||||||
|
--background: #0f172a; /* slate-900 — the page */
|
||||||
|
--foreground: #e2e8f0; /* slate-200 */
|
||||||
|
--card: #1e293b; /* slate-800 — panels lift off the page */
|
||||||
|
--card-foreground: #e2e8f0;
|
||||||
|
--popover: #1e293b;
|
||||||
|
--popover-foreground: #e2e8f0;
|
||||||
|
--primary: #3b82f6; /* blue-500 — active tabs, focus, buttons */
|
||||||
|
--primary-foreground: #f8fafc;
|
||||||
|
--secondary: #273449;
|
||||||
|
--secondary-foreground: #e2e8f0;
|
||||||
|
--muted: #1c2941; /* toolbars / table headers */
|
||||||
|
--muted-foreground: #94a3b8; /* slate-400 — DXHunter's muted text */
|
||||||
|
--accent: #2c3b54; /* hover / selection tint */
|
||||||
|
--accent-foreground: #cbd5e1;
|
||||||
|
--destructive: #ef4444;
|
||||||
|
--destructive-foreground: #fef2f2;
|
||||||
|
--destructive-muted: #3a1518;
|
||||||
|
--destructive-muted-foreground: #fca5a5;
|
||||||
|
--border: #334155; /* slate-700 — every rule in DXHunter */
|
||||||
|
--input: #334155;
|
||||||
|
--ring: #60a5fa; /* blue-400 — its focus border */
|
||||||
|
|
||||||
|
--success: #34d399; /* emerald-400 — "online", confirmed */
|
||||||
|
--success-foreground: #04211a;
|
||||||
|
--success-muted: #0e3029;
|
||||||
|
--success-muted-foreground: #6ee7b7;
|
||||||
|
--success-border: #17564a;
|
||||||
|
|
||||||
|
--warning: #fbbf24; /* amber-400 */
|
||||||
|
--warning-foreground: #211803;
|
||||||
|
--warning-muted: #33280f;
|
||||||
|
--warning-muted-foreground: #fcd34d;
|
||||||
|
--warning-border: #4f3e15;
|
||||||
|
|
||||||
|
--caution: #facc15;
|
||||||
|
--caution-foreground: #211e04;
|
||||||
|
--caution-muted: #322d0e;
|
||||||
|
--caution-muted-foreground: #fde047;
|
||||||
|
--caution-border: #4b4315;
|
||||||
|
|
||||||
|
--danger: #f87171; /* red-400 — rose is barely used there */
|
||||||
|
--danger-foreground: #250912;
|
||||||
|
--danger-muted: #3a1621;
|
||||||
|
--danger-muted-foreground: #fda4af;
|
||||||
|
--danger-border: #5a2735;
|
||||||
|
|
||||||
|
--info: #22d3ee; /* cyan-400 — "heard near you" */
|
||||||
|
--info-foreground: #04212a;
|
||||||
|
--info-muted: #0c2f3b;
|
||||||
|
--info-muted-foreground: #67e8f9;
|
||||||
|
--info-border: #155e6e;
|
||||||
|
|
||||||
|
/* Blue is the primary, so the entity ramp goes VIOLET rather than reading
|
||||||
|
as a button — and violet is where DXHunter puts its own second accent. */
|
||||||
|
--mx-call-conf: #22c55e;
|
||||||
|
--mx-call-work: #2c7a52;
|
||||||
|
--mx-dx-conf: #a78bfa;
|
||||||
|
--mx-dx-work: #5b4a9e;
|
||||||
|
--mx-none: #334155;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #334155; /* DXHunter's own scrollbar */
|
||||||
|
--scrollbar-thumb-hover: #475569;
|
||||||
|
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(226, 232, 240, 0.05);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 14: DXHunter orange — the same slate, OpsLog's own accent --
|
||||||
|
DXHunter's slate, kept exactly — page, panels, rules, muted text, and its
|
||||||
|
status colours — with OpsLog's orange in place of its blue. For an operator
|
||||||
|
who wants the two windows to sit together without OpsLog losing the accent
|
||||||
|
it is recognised by. The entity ramp goes VIOLET here for the same reason as
|
||||||
|
in the blue version: it must not read as the accent. */
|
||||||
|
[data-theme="dxhunter-orange"] {
|
||||||
|
--background: #0f172a; /* slate-900 — the page */
|
||||||
|
--foreground: #e2e8f0; /* slate-200 */
|
||||||
|
--card: #1e293b; /* slate-800 — panels lift off the page */
|
||||||
|
--card-foreground: #e2e8f0;
|
||||||
|
--popover: #1e293b;
|
||||||
|
--popover-foreground: #e2e8f0;
|
||||||
|
--primary: #f97316; /* orange-500 — OpsLog's own accent */
|
||||||
|
--primary-foreground: #1c0a02;
|
||||||
|
--secondary: #273449;
|
||||||
|
--secondary-foreground: #e2e8f0;
|
||||||
|
--muted: #1c2941; /* toolbars / table headers */
|
||||||
|
--muted-foreground: #94a3b8; /* slate-400 — DXHunter's muted text */
|
||||||
|
--accent: #2c3b54; /* hover / selection tint */
|
||||||
|
--accent-foreground: #cbd5e1;
|
||||||
|
--destructive: #ef4444;
|
||||||
|
--destructive-foreground: #fef2f2;
|
||||||
|
--destructive-muted: #3a1518;
|
||||||
|
--destructive-muted-foreground: #fca5a5;
|
||||||
|
--border: #334155; /* slate-700 — every rule in DXHunter */
|
||||||
|
--input: #334155;
|
||||||
|
--ring: #fdba74; /* orange-300 focus ring */
|
||||||
|
|
||||||
|
--success: #34d399; /* emerald-400 — "online", confirmed */
|
||||||
|
--success-foreground: #04211a;
|
||||||
|
--success-muted: #0e3029;
|
||||||
|
--success-muted-foreground: #6ee7b7;
|
||||||
|
--success-border: #17564a;
|
||||||
|
|
||||||
|
--warning: #fbbf24; /* amber-400 */
|
||||||
|
--warning-foreground: #211803;
|
||||||
|
--warning-muted: #33280f;
|
||||||
|
--warning-muted-foreground: #fcd34d;
|
||||||
|
--warning-border: #4f3e15;
|
||||||
|
|
||||||
|
--caution: #facc15;
|
||||||
|
--caution-foreground: #211e04;
|
||||||
|
--caution-muted: #322d0e;
|
||||||
|
--caution-muted-foreground: #fde047;
|
||||||
|
--caution-border: #4b4315;
|
||||||
|
|
||||||
|
--danger: #f87171; /* red-400 — rose is barely used there */
|
||||||
|
--danger-foreground: #250912;
|
||||||
|
--danger-muted: #3a1621;
|
||||||
|
--danger-muted-foreground: #fda4af;
|
||||||
|
--danger-border: #5a2735;
|
||||||
|
|
||||||
|
--info: #22d3ee; /* cyan-400 — "heard near you" */
|
||||||
|
--info-foreground: #04212a;
|
||||||
|
--info-muted: #0c2f3b;
|
||||||
|
--info-muted-foreground: #67e8f9;
|
||||||
|
--info-border: #155e6e;
|
||||||
|
|
||||||
|
/* Blue is the primary, so the entity ramp goes VIOLET rather than reading
|
||||||
|
as a button — and violet is where DXHunter puts its own second accent. */
|
||||||
|
--mx-call-conf: #22c55e;
|
||||||
|
--mx-call-work: #2c7a52;
|
||||||
|
--mx-dx-conf: #a78bfa;
|
||||||
|
--mx-dx-work: #5b4a9e;
|
||||||
|
--mx-none: #334155;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #334155; /* DXHunter's own scrollbar */
|
||||||
|
--scrollbar-thumb-hover: #475569;
|
||||||
|
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(226, 232, 240, 0.05);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
|
/* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
|
||||||
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
|
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
|
||||||
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
|
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
|
||||||
@@ -900,7 +1055,9 @@
|
|||||||
[data-theme="high-contrast"],
|
[data-theme="high-contrast"],
|
||||||
[data-theme="dark-indigo"],
|
[data-theme="dark-indigo"],
|
||||||
[data-theme="dark-teal"],
|
[data-theme="dark-teal"],
|
||||||
[data-theme="dark-plum"] {
|
[data-theme="dark-plum"],
|
||||||
|
[data-theme="dxhunter"],
|
||||||
|
[data-theme="dxhunter-orange"] {
|
||||||
--chart-1: #3987e5;
|
--chart-1: #3987e5;
|
||||||
--chart-2: #199e70;
|
--chart-2: #199e70;
|
||||||
--chart-3: #c98500;
|
--chart-3: #c98500;
|
||||||
@@ -981,6 +1138,8 @@
|
|||||||
--color-mx-dx-work: var(--mx-dx-work);
|
--color-mx-dx-work: var(--mx-dx-work);
|
||||||
--color-mx-none: var(--mx-none);
|
--color-mx-none: var(--mx-none);
|
||||||
--color-mx-cur: var(--mx-cur);
|
--color-mx-cur: var(--mx-cur);
|
||||||
|
--color-mx-mark-work: var(--mx-mark-work);
|
||||||
|
--color-mx-mark-conf: var(--mx-mark-conf);
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
@@ -1079,3 +1238,10 @@
|
|||||||
.overflow-scroll {
|
.overflow-scroll {
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The ground beside the planet, on every map: Leaflet paints its container a
|
||||||
|
hard-coded light grey (#ddd), which reads as a broken tile against any
|
||||||
|
theme. The surround follows the theme's own card surface instead. */
|
||||||
|
.leaflet-container {
|
||||||
|
background: var(--card) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.26.12';
|
export const APP_VERSION = '0.27.13';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+167
-10
@@ -12,8 +12,10 @@ import {award} from '../models';
|
|||||||
import {awardref} from '../models';
|
import {awardref} from '../models';
|
||||||
import {bandopen} from '../models';
|
import {bandopen} from '../models';
|
||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
|
import {dxped} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
|
import {pskrtgt} from '../models';
|
||||||
import {pskr} from '../models';
|
import {pskr} from '../models';
|
||||||
import {psu} from '../models';
|
import {psu} from '../models';
|
||||||
import {spe} from '../models';
|
import {spe} from '../models';
|
||||||
@@ -30,6 +32,7 @@ import {lotwusers} from '../models';
|
|||||||
import {lookup} from '../models';
|
import {lookup} from '../models';
|
||||||
import {netctl} from '../models';
|
import {netctl} from '../models';
|
||||||
import {scp} from '../models';
|
import {scp} from '../models';
|
||||||
|
import {watchlist} from '../models';
|
||||||
|
|
||||||
export function ACOMSetOperate(arg1:boolean):Promise<void>;
|
export function ACOMSetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
@@ -43,6 +46,10 @@ export function ADIFVersion():Promise<string>;
|
|||||||
|
|
||||||
export function ActivateProfile(arg1:number):Promise<void>;
|
export function ActivateProfile(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function ActiveRadioID():Promise<string>;
|
||||||
|
|
||||||
|
export function ActiveRadioMyRig():Promise<string>;
|
||||||
|
|
||||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||||
|
|
||||||
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
|
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
|
||||||
@@ -65,6 +72,8 @@ export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
|||||||
|
|
||||||
export function ApplyAwardUpdate(arg1:string):Promise<void>;
|
export function ApplyAwardUpdate(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function ApplyRDAChoices(arg1:Array<main.RDAChoice>):Promise<main.RDAApplyResult>;
|
||||||
|
|
||||||
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||||
|
|
||||||
export function AudioApplyLevels(arg1:number,arg2:number):Promise<void>;
|
export function AudioApplyLevels(arg1:number,arg2:number):Promise<void>;
|
||||||
@@ -135,6 +144,8 @@ export function ComputeQSOAwardRefs(arg1:qso.QSO):Promise<Array<main.QSOAwardRef
|
|||||||
|
|
||||||
export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
|
export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
|
||||||
|
|
||||||
|
export function ConfigureDecoderMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function ConnectAllClusters():Promise<void>;
|
export function ConnectAllClusters():Promise<void>;
|
||||||
|
|
||||||
export function ConnectClusterServer(arg1:number):Promise<void>;
|
export function ConnectClusterServer(arg1:number):Promise<void>;
|
||||||
@@ -151,6 +162,8 @@ export function CreateDatabase(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function DVKCancelRecord():Promise<void>;
|
export function DVKCancelRecord():Promise<void>;
|
||||||
|
|
||||||
|
export function DVKDelete(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function DVKPlay(arg1:number):Promise<void>;
|
export function DVKPlay(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function DVKPreview(arg1:number):Promise<void>;
|
export function DVKPreview(arg1:number):Promise<void>;
|
||||||
@@ -399,8 +412,14 @@ export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
|
|||||||
|
|
||||||
export function GetAntGeniusStatus():Promise<antgenius.Status>;
|
export function GetAntGeniusStatus():Promise<antgenius.Status>;
|
||||||
|
|
||||||
|
export function GetAudioMonitorPref():Promise<boolean>;
|
||||||
|
|
||||||
export function GetAudioSettings():Promise<main.AudioSettings>;
|
export function GetAudioSettings():Promise<main.AudioSettings>;
|
||||||
|
|
||||||
|
export function GetAutoCallSettings():Promise<main.AutoCallSettings>;
|
||||||
|
|
||||||
|
export function GetAutoCallStatus():Promise<main.AutoCallStatus>;
|
||||||
|
|
||||||
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
||||||
|
|
||||||
export function GetAward(arg1:string,arg2:string):Promise<award.Result>;
|
export function GetAward(arg1:string,arg2:string):Promise<award.Result>;
|
||||||
@@ -435,10 +454,14 @@ export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
|||||||
|
|
||||||
export function GetChaseNew():Promise<boolean>;
|
export function GetChaseNew():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetChaseNewBands():Promise<Array<string>>;
|
||||||
|
|
||||||
export function GetChaseNewGrids():Promise<boolean>;
|
export function GetChaseNewGrids():Promise<boolean>;
|
||||||
|
|
||||||
export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>;
|
export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>;
|
||||||
|
|
||||||
|
export function GetChaseSettings():Promise<main.ChaseSettings>;
|
||||||
|
|
||||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||||
|
|
||||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||||
@@ -461,6 +484,10 @@ export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
|
|||||||
|
|
||||||
export function GetDVKStatus():Promise<main.DVKStatus>;
|
export function GetDVKStatus():Promise<main.DVKStatus>;
|
||||||
|
|
||||||
|
export function GetDXWorldNews():Promise<Array<dxped.News>>;
|
||||||
|
|
||||||
|
export function GetDXpeditions():Promise<Array<main.DXpedition>>;
|
||||||
|
|
||||||
export function GetDataDir():Promise<string>;
|
export function GetDataDir():Promise<string>;
|
||||||
|
|
||||||
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
|
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
|
||||||
@@ -499,6 +526,10 @@ export function GetLiveOpenings():Promise<Array<bandopen.Opening>>;
|
|||||||
|
|
||||||
export function GetLiveStations():Promise<Array<main.LiveStation>>;
|
export function GetLiveStations():Promise<Array<main.LiveStation>>;
|
||||||
|
|
||||||
|
export function GetLoTWDownloadAllCalls():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetLoTWQSLDetail():Promise<boolean>;
|
||||||
|
|
||||||
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||||
|
|
||||||
export function GetLogFilePath():Promise<string>;
|
export function GetLogFilePath():Promise<string>;
|
||||||
@@ -525,8 +556,12 @@ export function GetPGXLStatus():Promise<powergenius.Status>;
|
|||||||
|
|
||||||
export function GetPOTAToken():Promise<string>;
|
export function GetPOTAToken():Promise<string>;
|
||||||
|
|
||||||
|
export function GetPSKAnalysis():Promise<pskrtgt.Analysis>;
|
||||||
|
|
||||||
export function GetPSKReporterStatus():Promise<pskr.Status>;
|
export function GetPSKReporterStatus():Promise<pskr.Status>;
|
||||||
|
|
||||||
|
export function GetPSKTargetSettings():Promise<main.PSKTargetSettings>;
|
||||||
|
|
||||||
export function GetPSUSettings():Promise<main.PSUSettings>;
|
export function GetPSUSettings():Promise<main.PSUSettings>;
|
||||||
|
|
||||||
export function GetPSUStatus():Promise<psu.Status>;
|
export function GetPSUStatus():Promise<psu.Status>;
|
||||||
@@ -539,6 +574,8 @@ export function GetQSO(arg1:number):Promise<qso.QSO>;
|
|||||||
|
|
||||||
export function GetQSORate():Promise<main.QSORate>;
|
export function GetQSORate():Promise<main.QSORate>;
|
||||||
|
|
||||||
|
export function GetRadios():Promise<Array<main.RadioConfig>>;
|
||||||
|
|
||||||
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
||||||
|
|
||||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||||
@@ -575,9 +612,7 @@ export function GetStationSettings():Promise<main.StationSettings>;
|
|||||||
|
|
||||||
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
||||||
|
|
||||||
export function GetTCIAudioStatus():Promise<cat.TCIAudioStatus>;
|
export function GetTCIPanel():Promise<cat.TCIPanelState>;
|
||||||
|
|
||||||
export function GetTCIRecordAudio():Promise<boolean>;
|
|
||||||
|
|
||||||
export function GetTelemetryEnabled():Promise<boolean>;
|
export function GetTelemetryEnabled():Promise<boolean>;
|
||||||
|
|
||||||
@@ -593,6 +628,10 @@ export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
|||||||
|
|
||||||
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
||||||
|
|
||||||
|
export function GetWatchlistContestCalls():Promise<string>;
|
||||||
|
|
||||||
|
export function GetWatchlistContestPattern():Promise<string>;
|
||||||
|
|
||||||
export function GetWebPublishConfig():Promise<webpub.Config>;
|
export function GetWebPublishConfig():Promise<webpub.Config>;
|
||||||
|
|
||||||
export function GetWebPublishStatus():Promise<main.WebPublishStatus>;
|
export function GetWebPublishStatus():Promise<main.WebPublishStatus>;
|
||||||
@@ -605,16 +644,28 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
|||||||
|
|
||||||
export function GetWorkedCallVariants():Promise<boolean>;
|
export function GetWorkedCallVariants():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetWsjtFollowMode():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetWsjtHighlight():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetWsjtHighlightWorked():Promise<boolean>;
|
||||||
|
|
||||||
export function GetYaesuBandAntennas():Promise<Record<string, number>>;
|
export function GetYaesuBandAntennas():Promise<Record<string, number>>;
|
||||||
|
|
||||||
export function GetYaesuState():Promise<cat.YaesuTXState>;
|
export function GetYaesuState():Promise<cat.YaesuTXState>;
|
||||||
|
|
||||||
export function GridSquares(arg1:string):Promise<Array<qso.GridSquare>>;
|
export function GridSquares(arg1:string):Promise<Array<qso.GridSquare>>;
|
||||||
|
|
||||||
|
export function HaltAutoCall():Promise<void>;
|
||||||
|
|
||||||
export function HaltDecodeTx(arg1:string,arg2:boolean):Promise<void>;
|
export function HaltDecodeTx(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
||||||
|
|
||||||
|
export function IcomConsolePTT(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomRecallBand(arg1:string,arg2:number):Promise<number>;
|
||||||
|
|
||||||
export function IcomRefresh():Promise<void>;
|
export function IcomRefresh():Promise<void>;
|
||||||
|
|
||||||
export function IcomScopeData():Promise<cat.ScopeSweep>;
|
export function IcomScopeData():Promise<cat.ScopeSweep>;
|
||||||
@@ -629,6 +680,8 @@ export function IcomSetANF(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function IcomSetAPF(arg1:boolean):Promise<void>;
|
export function IcomSetAPF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetATU(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetAntenna(arg1:number):Promise<void>;
|
export function IcomSetAntenna(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetAntiVOX(arg1:number):Promise<void>;
|
export function IcomSetAntiVOX(arg1:number):Promise<void>;
|
||||||
@@ -689,6 +742,8 @@ export function IcomSetScopeMode(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function IcomSetSplit(arg1:boolean):Promise<void>;
|
export function IcomSetSplit(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetSplitOffset(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetSquelch(arg1:number):Promise<void>;
|
export function IcomSetSquelch(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetVOX(arg1:boolean):Promise<void>;
|
export function IcomSetVOX(arg1:boolean):Promise<void>;
|
||||||
@@ -745,6 +800,8 @@ export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
|
|||||||
|
|
||||||
export function ListQSOFiltered(arg1:qso.QueryFilter):Promise<Array<qso.QSO>>;
|
export function ListQSOFiltered(arg1:qso.QueryFilter):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
|
export function ListRadios():Promise<Array<main.RadioListEntry>>;
|
||||||
|
|
||||||
export function ListSerialPorts():Promise<Array<string>>;
|
export function ListSerialPorts():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
||||||
@@ -807,6 +864,8 @@ export function NetSetDefaults(arg1:string,arg2:string,arg3:string,arg4:string):
|
|||||||
|
|
||||||
export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
|
export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
|
||||||
|
|
||||||
|
export function NudgeKenwoodRIT(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function OpenADIFFile():Promise<string>;
|
export function OpenADIFFile():Promise<string>;
|
||||||
|
|
||||||
export function OpenAwardsFolder():Promise<void>;
|
export function OpenAwardsFolder():Promise<void>;
|
||||||
@@ -907,10 +966,10 @@ export function RecomputeAllAwardRefs():Promise<number>;
|
|||||||
|
|
||||||
export function RecomputeAwardRefsForCode(arg1:string):Promise<number>;
|
export function RecomputeAwardRefsForCode(arg1:string):Promise<number>;
|
||||||
|
|
||||||
export function RecordTCIAudio(arg1:number):Promise<string>;
|
|
||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
|
export function RefreshDXpeditions():Promise<void>;
|
||||||
|
|
||||||
export function RefreshKenwood():Promise<void>;
|
export function RefreshKenwood():Promise<void>;
|
||||||
|
|
||||||
export function RefreshSolar():Promise<void>;
|
export function RefreshSolar():Promise<void>;
|
||||||
@@ -937,6 +996,8 @@ export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise<
|
|||||||
|
|
||||||
export function RescanAwards():Promise<void>;
|
export function RescanAwards():Promise<void>;
|
||||||
|
|
||||||
|
export function ResetAutoCall():Promise<void>;
|
||||||
|
|
||||||
export function ResetAwardDefs():Promise<Array<award.Def>>;
|
export function ResetAwardDefs():Promise<Array<award.Def>>;
|
||||||
|
|
||||||
export function ResetDatabaseToDefault():Promise<void>;
|
export function ResetDatabaseToDefault():Promise<void>;
|
||||||
@@ -983,6 +1044,8 @@ export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>
|
|||||||
|
|
||||||
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveAutoCallSettings(arg1:main.AutoCallSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
|
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
|
||||||
|
|
||||||
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
|
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
|
||||||
@@ -997,6 +1060,8 @@ export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SaveCabrilloFile():Promise<string>;
|
export function SaveCabrilloFile():Promise<string>;
|
||||||
|
|
||||||
|
export function SaveChaseSettings(arg1:main.ChaseSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
||||||
|
|
||||||
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
|
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
|
||||||
@@ -1031,12 +1096,16 @@ export function SavePGXLSettings(arg1:main.PGXLSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SavePOTAToken(arg1:string):Promise<void>;
|
export function SavePOTAToken(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SavePSKTargetSettings(arg1:main.PSKTargetSettings):Promise<void>;
|
||||||
|
|
||||||
export function SavePSUSettings(arg1:main.PSUSettings):Promise<void>;
|
export function SavePSUSettings(arg1:main.PSUSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||||
|
|
||||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveRadios(arg1:Array<main.RadioConfig>):Promise<void>;
|
||||||
|
|
||||||
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||||
|
|
||||||
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
|
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
|
||||||
@@ -1083,10 +1152,18 @@ export function SendLogToDeveloper():Promise<void>;
|
|||||||
|
|
||||||
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetActiveRadio(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetActiveRotor(arg1:number):Promise<void>;
|
export function SetActiveRotor(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetAutoCall(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetAutoCallOnly(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetAutoCallVisible(arg1:Array<string>,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetCATMode(arg1:string):Promise<void>;
|
export function SetCATMode(arg1:string):Promise<void>;
|
||||||
@@ -1097,6 +1174,8 @@ export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function SetChaseNew(arg1:boolean):Promise<void>;
|
export function SetChaseNew(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetChaseNewBands(arg1:Array<string>):Promise<void>;
|
||||||
|
|
||||||
export function SetChaseNewGrids(arg1:boolean):Promise<void>;
|
export function SetChaseNewGrids(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||||
@@ -1131,6 +1210,8 @@ export function SetKenwoodNB(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetKenwoodNR(arg1:boolean):Promise<void>;
|
export function SetKenwoodNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetKenwoodPanelMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetKenwoodPower(arg1:number):Promise<void>;
|
export function SetKenwoodPower(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetKenwoodPreamp(arg1:boolean):Promise<void>;
|
export function SetKenwoodPreamp(arg1:boolean):Promise<void>;
|
||||||
@@ -1139,6 +1220,8 @@ export function SetKenwoodRFGain(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function SetKenwoodRIT(arg1:boolean):Promise<void>;
|
export function SetKenwoodRIT(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetKenwoodRITOffset(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetKenwoodSquelch(arg1:number):Promise<void>;
|
export function SetKenwoodSquelch(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetKenwoodTX(arg1:boolean):Promise<void>;
|
export function SetKenwoodTX(arg1:boolean):Promise<void>;
|
||||||
@@ -1147,21 +1230,65 @@ export function SetKenwoodXIT(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetLinkedAmps(arg1:Array<string>):Promise<void>;
|
export function SetLinkedAmps(arg1:Array<string>):Promise<void>;
|
||||||
|
|
||||||
|
export function SetLoTWDownloadAllCalls(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetLoTWQSLDetail(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<void>;
|
export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<void>;
|
||||||
|
|
||||||
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetPSKTarget(arg1:string,arg2:string,arg3:number):Promise<void>;
|
||||||
|
|
||||||
export function SetPSUOutput(arg1:boolean):Promise<void>;
|
export function SetPSUOutput(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetPassphrase(arg1:string):Promise<void>;
|
export function SetPassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetScpClublogEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetSpotMax(arg1:number):Promise<void>;
|
export function SetSpotMax(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetSpotTTLMinutes(arg1:number):Promise<void>;
|
export function SetSpotTTLMinutes(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetTCIRecordAudio(arg1:boolean):Promise<void>;
|
export function SetTCIAGC(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCIANF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCIAPF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCIDrive(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCIFilter(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCILock(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCIMicLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCIMute(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCINB(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCINR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCIRIT(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCIRITOffset(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCISquelch(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCISquelchLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCITune(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCITuneDrive(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCIVolume(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCIXIT(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTCIXITOffset(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
@@ -1169,10 +1296,20 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetWatchlistContestCalls(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetWatchlistContestPattern(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
|
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetWorkedCallVariants(arg1:boolean):Promise<void>;
|
export function SetWorkedCallVariants(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetWsjtFollowMode(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetWsjtHighlight(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetWsjtHighlightWorked(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetYaesuAFGain(arg1:number):Promise<void>;
|
export function SetYaesuAFGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetYaesuAGC(arg1:string):Promise<void>;
|
export function SetYaesuAGC(arg1:string):Promise<void>;
|
||||||
@@ -1215,22 +1352,26 @@ export function SetYaesuVOX(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function StartCWDecoder():Promise<void>;
|
export function StartCWDecoder():Promise<void>;
|
||||||
|
|
||||||
export function StartTCIAudio(arg1:number,arg2:number):Promise<void>;
|
|
||||||
|
|
||||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||||
|
|
||||||
export function StopCWDecoder():Promise<void>;
|
export function StopCWDecoder():Promise<void>;
|
||||||
|
|
||||||
export function StopTCIAudio():Promise<void>;
|
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SyncFolderNow():Promise<number>;
|
export function SyncFolderNow():Promise<number>;
|
||||||
|
|
||||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||||
|
|
||||||
|
export function TCISendCW(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function TCISetKeySpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function TCIStopCW():Promise<void>;
|
||||||
|
|
||||||
export function TailLogFile(arg1:number):Promise<string>;
|
export function TailLogFile(arg1:number):Promise<string>;
|
||||||
|
|
||||||
|
export function TakeAutoCallTarget(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|
||||||
export function TestCloudlogUpload():Promise<string>;
|
export function TestCloudlogUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestClublogUpload():Promise<string>;
|
export function TestClublogUpload():Promise<string>;
|
||||||
@@ -1241,6 +1382,8 @@ export function TestEmail(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function TestHRDLogUpload():Promise<string>;
|
export function TestHRDLogUpload():Promise<string>;
|
||||||
|
|
||||||
|
export function TestHamQTHUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestLoTWUpload():Promise<string>;
|
export function TestLoTWUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||||
@@ -1295,8 +1438,22 @@ export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
|
|||||||
|
|
||||||
export function UploadCallsign(arg1:string):Promise<string>;
|
export function UploadCallsign(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function UploadFullLogHamQTH():Promise<void>;
|
||||||
|
|
||||||
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||||
|
|
||||||
|
export function WatchlistAdd(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function WatchlistEntries():Promise<Array<watchlist.Entry>>;
|
||||||
|
|
||||||
|
export function WatchlistRemove(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function WatchlistSetContest(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function WatchlistSetNotify(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function WatchlistWorkedSlots(arg1:Array<main.WatchlistSlotQuery>):Promise<Array<boolean>>;
|
||||||
|
|
||||||
export function WebPublishColumns():Promise<Array<Record<string, string>>>;
|
export function WebPublishColumns():Promise<Array<Record<string, string>>>;
|
||||||
|
|
||||||
export function WinkeyerBackspace():Promise<void>;
|
export function WinkeyerBackspace():Promise<void>;
|
||||||
|
|||||||
+328
-20
@@ -26,6 +26,14 @@ export function ActivateProfile(arg1) {
|
|||||||
return window['go']['main']['App']['ActivateProfile'](arg1);
|
return window['go']['main']['App']['ActivateProfile'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ActiveRadioID() {
|
||||||
|
return window['go']['main']['App']['ActiveRadioID']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActiveRadioMyRig() {
|
||||||
|
return window['go']['main']['App']['ActiveRadioMyRig']();
|
||||||
|
}
|
||||||
|
|
||||||
export function AddQSO(arg1) {
|
export function AddQSO(arg1) {
|
||||||
return window['go']['main']['App']['AddQSO'](arg1);
|
return window['go']['main']['App']['AddQSO'](arg1);
|
||||||
}
|
}
|
||||||
@@ -70,6 +78,10 @@ export function ApplyAwardUpdate(arg1) {
|
|||||||
return window['go']['main']['App']['ApplyAwardUpdate'](arg1);
|
return window['go']['main']['App']['ApplyAwardUpdate'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ApplyRDAChoices(arg1) {
|
||||||
|
return window['go']['main']['App']['ApplyRDAChoices'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
@@ -210,6 +222,10 @@ export function ComputeStationInfo(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['ComputeStationInfo'](arg1, arg2);
|
return window['go']['main']['App']['ComputeStationInfo'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ConfigureDecoderMode(arg1) {
|
||||||
|
return window['go']['main']['App']['ConfigureDecoderMode'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ConnectAllClusters() {
|
export function ConnectAllClusters() {
|
||||||
return window['go']['main']['App']['ConnectAllClusters']();
|
return window['go']['main']['App']['ConnectAllClusters']();
|
||||||
}
|
}
|
||||||
@@ -242,6 +258,10 @@ export function DVKCancelRecord() {
|
|||||||
return window['go']['main']['App']['DVKCancelRecord']();
|
return window['go']['main']['App']['DVKCancelRecord']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DVKDelete(arg1) {
|
||||||
|
return window['go']['main']['App']['DVKDelete'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DVKPlay(arg1) {
|
export function DVKPlay(arg1) {
|
||||||
return window['go']['main']['App']['DVKPlay'](arg1);
|
return window['go']['main']['App']['DVKPlay'](arg1);
|
||||||
}
|
}
|
||||||
@@ -738,10 +758,22 @@ export function GetAntGeniusStatus() {
|
|||||||
return window['go']['main']['App']['GetAntGeniusStatus']();
|
return window['go']['main']['App']['GetAntGeniusStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAudioMonitorPref() {
|
||||||
|
return window['go']['main']['App']['GetAudioMonitorPref']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAudioSettings() {
|
export function GetAudioSettings() {
|
||||||
return window['go']['main']['App']['GetAudioSettings']();
|
return window['go']['main']['App']['GetAudioSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAutoCallSettings() {
|
||||||
|
return window['go']['main']['App']['GetAutoCallSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetAutoCallStatus() {
|
||||||
|
return window['go']['main']['App']['GetAutoCallStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAutostartPrograms() {
|
export function GetAutostartPrograms() {
|
||||||
return window['go']['main']['App']['GetAutostartPrograms']();
|
return window['go']['main']['App']['GetAutostartPrograms']();
|
||||||
}
|
}
|
||||||
@@ -810,6 +842,10 @@ export function GetChaseNew() {
|
|||||||
return window['go']['main']['App']['GetChaseNew']();
|
return window['go']['main']['App']['GetChaseNew']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetChaseNewBands() {
|
||||||
|
return window['go']['main']['App']['GetChaseNewBands']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetChaseNewGrids() {
|
export function GetChaseNewGrids() {
|
||||||
return window['go']['main']['App']['GetChaseNewGrids']();
|
return window['go']['main']['App']['GetChaseNewGrids']();
|
||||||
}
|
}
|
||||||
@@ -818,6 +854,10 @@ export function GetChaseNewSpots() {
|
|||||||
return window['go']['main']['App']['GetChaseNewSpots']();
|
return window['go']['main']['App']['GetChaseNewSpots']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetChaseSettings() {
|
||||||
|
return window['go']['main']['App']['GetChaseSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetChatHistory(arg1) {
|
export function GetChatHistory(arg1) {
|
||||||
return window['go']['main']['App']['GetChatHistory'](arg1);
|
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||||
}
|
}
|
||||||
@@ -862,6 +902,14 @@ export function GetDVKStatus() {
|
|||||||
return window['go']['main']['App']['GetDVKStatus']();
|
return window['go']['main']['App']['GetDVKStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetDXWorldNews() {
|
||||||
|
return window['go']['main']['App']['GetDXWorldNews']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetDXpeditions() {
|
||||||
|
return window['go']['main']['App']['GetDXpeditions']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetDataDir() {
|
export function GetDataDir() {
|
||||||
return window['go']['main']['App']['GetDataDir']();
|
return window['go']['main']['App']['GetDataDir']();
|
||||||
}
|
}
|
||||||
@@ -938,6 +986,14 @@ export function GetLiveStations() {
|
|||||||
return window['go']['main']['App']['GetLiveStations']();
|
return window['go']['main']['App']['GetLiveStations']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLoTWDownloadAllCalls() {
|
||||||
|
return window['go']['main']['App']['GetLoTWDownloadAllCalls']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetLoTWQSLDetail() {
|
||||||
|
return window['go']['main']['App']['GetLoTWQSLDetail']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetLoTWUsersStatus() {
|
export function GetLoTWUsersStatus() {
|
||||||
return window['go']['main']['App']['GetLoTWUsersStatus']();
|
return window['go']['main']['App']['GetLoTWUsersStatus']();
|
||||||
}
|
}
|
||||||
@@ -990,10 +1046,18 @@ export function GetPOTAToken() {
|
|||||||
return window['go']['main']['App']['GetPOTAToken']();
|
return window['go']['main']['App']['GetPOTAToken']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetPSKAnalysis() {
|
||||||
|
return window['go']['main']['App']['GetPSKAnalysis']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPSKReporterStatus() {
|
export function GetPSKReporterStatus() {
|
||||||
return window['go']['main']['App']['GetPSKReporterStatus']();
|
return window['go']['main']['App']['GetPSKReporterStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetPSKTargetSettings() {
|
||||||
|
return window['go']['main']['App']['GetPSKTargetSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPSUSettings() {
|
export function GetPSUSettings() {
|
||||||
return window['go']['main']['App']['GetPSUSettings']();
|
return window['go']['main']['App']['GetPSUSettings']();
|
||||||
}
|
}
|
||||||
@@ -1018,6 +1082,10 @@ export function GetQSORate() {
|
|||||||
return window['go']['main']['App']['GetQSORate']();
|
return window['go']['main']['App']['GetQSORate']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetRadios() {
|
||||||
|
return window['go']['main']['App']['GetRadios']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetRelayAuto() {
|
export function GetRelayAuto() {
|
||||||
return window['go']['main']['App']['GetRelayAuto']();
|
return window['go']['main']['App']['GetRelayAuto']();
|
||||||
}
|
}
|
||||||
@@ -1090,12 +1158,8 @@ export function GetStationStatus() {
|
|||||||
return window['go']['main']['App']['GetStationStatus']();
|
return window['go']['main']['App']['GetStationStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetTCIAudioStatus() {
|
export function GetTCIPanel() {
|
||||||
return window['go']['main']['App']['GetTCIAudioStatus']();
|
return window['go']['main']['App']['GetTCIPanel']();
|
||||||
}
|
|
||||||
|
|
||||||
export function GetTCIRecordAudio() {
|
|
||||||
return window['go']['main']['App']['GetTCIRecordAudio']();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetTelemetryEnabled() {
|
export function GetTelemetryEnabled() {
|
||||||
@@ -1126,6 +1190,14 @@ export function GetUltrabeamStatus() {
|
|||||||
return window['go']['main']['App']['GetUltrabeamStatus']();
|
return window['go']['main']['App']['GetUltrabeamStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetWatchlistContestCalls() {
|
||||||
|
return window['go']['main']['App']['GetWatchlistContestCalls']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetWatchlistContestPattern() {
|
||||||
|
return window['go']['main']['App']['GetWatchlistContestPattern']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetWebPublishConfig() {
|
export function GetWebPublishConfig() {
|
||||||
return window['go']['main']['App']['GetWebPublishConfig']();
|
return window['go']['main']['App']['GetWebPublishConfig']();
|
||||||
}
|
}
|
||||||
@@ -1150,6 +1222,18 @@ export function GetWorkedCallVariants() {
|
|||||||
return window['go']['main']['App']['GetWorkedCallVariants']();
|
return window['go']['main']['App']['GetWorkedCallVariants']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetWsjtFollowMode() {
|
||||||
|
return window['go']['main']['App']['GetWsjtFollowMode']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetWsjtHighlight() {
|
||||||
|
return window['go']['main']['App']['GetWsjtHighlight']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetWsjtHighlightWorked() {
|
||||||
|
return window['go']['main']['App']['GetWsjtHighlightWorked']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetYaesuBandAntennas() {
|
export function GetYaesuBandAntennas() {
|
||||||
return window['go']['main']['App']['GetYaesuBandAntennas']();
|
return window['go']['main']['App']['GetYaesuBandAntennas']();
|
||||||
}
|
}
|
||||||
@@ -1162,6 +1246,10 @@ export function GridSquares(arg1) {
|
|||||||
return window['go']['main']['App']['GridSquares'](arg1);
|
return window['go']['main']['App']['GridSquares'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function HaltAutoCall() {
|
||||||
|
return window['go']['main']['App']['HaltAutoCall']();
|
||||||
|
}
|
||||||
|
|
||||||
export function HaltDecodeTx(arg1, arg2) {
|
export function HaltDecodeTx(arg1, arg2) {
|
||||||
return window['go']['main']['App']['HaltDecodeTx'](arg1, arg2);
|
return window['go']['main']['App']['HaltDecodeTx'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -1170,6 +1258,14 @@ export function HasBuiltinReferences(arg1) {
|
|||||||
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
|
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IcomConsolePTT(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomConsolePTT'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomRecallBand(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['IcomRecallBand'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function IcomRefresh() {
|
export function IcomRefresh() {
|
||||||
return window['go']['main']['App']['IcomRefresh']();
|
return window['go']['main']['App']['IcomRefresh']();
|
||||||
}
|
}
|
||||||
@@ -1198,6 +1294,10 @@ export function IcomSetAPF(arg1) {
|
|||||||
return window['go']['main']['App']['IcomSetAPF'](arg1);
|
return window['go']['main']['App']['IcomSetAPF'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IcomSetATU(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetATU'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function IcomSetAntenna(arg1) {
|
export function IcomSetAntenna(arg1) {
|
||||||
return window['go']['main']['App']['IcomSetAntenna'](arg1);
|
return window['go']['main']['App']['IcomSetAntenna'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1318,6 +1418,10 @@ export function IcomSetSplit(arg1) {
|
|||||||
return window['go']['main']['App']['IcomSetSplit'](arg1);
|
return window['go']['main']['App']['IcomSetSplit'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IcomSetSplitOffset(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetSplitOffset'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function IcomSetSquelch(arg1) {
|
export function IcomSetSquelch(arg1) {
|
||||||
return window['go']['main']['App']['IcomSetSquelch'](arg1);
|
return window['go']['main']['App']['IcomSetSquelch'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1430,6 +1534,10 @@ export function ListQSOFiltered(arg1) {
|
|||||||
return window['go']['main']['App']['ListQSOFiltered'](arg1);
|
return window['go']['main']['App']['ListQSOFiltered'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListRadios() {
|
||||||
|
return window['go']['main']['App']['ListRadios']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListSerialPorts() {
|
export function ListSerialPorts() {
|
||||||
return window['go']['main']['App']['ListSerialPorts']();
|
return window['go']['main']['App']['ListSerialPorts']();
|
||||||
}
|
}
|
||||||
@@ -1554,6 +1662,10 @@ export function NetUpdateActive(arg1) {
|
|||||||
return window['go']['main']['App']['NetUpdateActive'](arg1);
|
return window['go']['main']['App']['NetUpdateActive'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function NudgeKenwoodRIT(arg1) {
|
||||||
|
return window['go']['main']['App']['NudgeKenwoodRIT'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function OpenADIFFile() {
|
export function OpenADIFFile() {
|
||||||
return window['go']['main']['App']['OpenADIFFile']();
|
return window['go']['main']['App']['OpenADIFFile']();
|
||||||
}
|
}
|
||||||
@@ -1754,14 +1866,14 @@ export function RecomputeAwardRefsForCode(arg1) {
|
|||||||
return window['go']['main']['App']['RecomputeAwardRefsForCode'](arg1);
|
return window['go']['main']['App']['RecomputeAwardRefsForCode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RecordTCIAudio(arg1) {
|
|
||||||
return window['go']['main']['App']['RecordTCIAudio'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RefreshCtyDat() {
|
export function RefreshCtyDat() {
|
||||||
return window['go']['main']['App']['RefreshCtyDat']();
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RefreshDXpeditions() {
|
||||||
|
return window['go']['main']['App']['RefreshDXpeditions']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RefreshKenwood() {
|
export function RefreshKenwood() {
|
||||||
return window['go']['main']['App']['RefreshKenwood']();
|
return window['go']['main']['App']['RefreshKenwood']();
|
||||||
}
|
}
|
||||||
@@ -1814,6 +1926,10 @@ export function RescanAwards() {
|
|||||||
return window['go']['main']['App']['RescanAwards']();
|
return window['go']['main']['App']['RescanAwards']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ResetAutoCall() {
|
||||||
|
return window['go']['main']['App']['ResetAutoCall']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ResetAwardDefs() {
|
export function ResetAwardDefs() {
|
||||||
return window['go']['main']['App']['ResetAwardDefs']();
|
return window['go']['main']['App']['ResetAwardDefs']();
|
||||||
}
|
}
|
||||||
@@ -1906,6 +2022,10 @@ export function SaveAudioSettings(arg1) {
|
|||||||
return window['go']['main']['App']['SaveAudioSettings'](arg1);
|
return window['go']['main']['App']['SaveAudioSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveAutoCallSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveAutoCallSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveAutostartPrograms(arg1) {
|
export function SaveAutostartPrograms(arg1) {
|
||||||
return window['go']['main']['App']['SaveAutostartPrograms'](arg1);
|
return window['go']['main']['App']['SaveAutostartPrograms'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1934,6 +2054,10 @@ export function SaveCabrilloFile() {
|
|||||||
return window['go']['main']['App']['SaveCabrilloFile']();
|
return window['go']['main']['App']['SaveCabrilloFile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveChaseSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveChaseSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveClusterServer(arg1) {
|
export function SaveClusterServer(arg1) {
|
||||||
return window['go']['main']['App']['SaveClusterServer'](arg1);
|
return window['go']['main']['App']['SaveClusterServer'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2002,6 +2126,10 @@ export function SavePOTAToken(arg1) {
|
|||||||
return window['go']['main']['App']['SavePOTAToken'](arg1);
|
return window['go']['main']['App']['SavePOTAToken'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SavePSKTargetSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SavePSKTargetSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SavePSUSettings(arg1) {
|
export function SavePSUSettings(arg1) {
|
||||||
return window['go']['main']['App']['SavePSUSettings'](arg1);
|
return window['go']['main']['App']['SavePSUSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2014,6 +2142,10 @@ export function SaveQSLDefaults(arg1) {
|
|||||||
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveRadios(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveRadios'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveRelayAuto(arg1) {
|
export function SaveRelayAuto(arg1) {
|
||||||
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2106,6 +2238,10 @@ export function SendQSORecordingEmail(arg1) {
|
|||||||
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetActiveRadio(arg1) {
|
||||||
|
return window['go']['main']['App']['SetActiveRadio'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetActiveRotor(arg1) {
|
export function SetActiveRotor(arg1) {
|
||||||
return window['go']['main']['App']['SetActiveRotor'](arg1);
|
return window['go']['main']['App']['SetActiveRotor'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2114,6 +2250,18 @@ export function SetAlertEmailTo(arg1) {
|
|||||||
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
|
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetAutoCall(arg1) {
|
||||||
|
return window['go']['main']['App']['SetAutoCall'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetAutoCallOnly(arg1) {
|
||||||
|
return window['go']['main']['App']['SetAutoCallOnly'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetAutoCallVisible(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['SetAutoCallVisible'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetCATFrequency(arg1) {
|
export function SetCATFrequency(arg1) {
|
||||||
return window['go']['main']['App']['SetCATFrequency'](arg1);
|
return window['go']['main']['App']['SetCATFrequency'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2134,6 +2282,10 @@ export function SetChaseNew(arg1) {
|
|||||||
return window['go']['main']['App']['SetChaseNew'](arg1);
|
return window['go']['main']['App']['SetChaseNew'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetChaseNewBands(arg1) {
|
||||||
|
return window['go']['main']['App']['SetChaseNewBands'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetChaseNewGrids(arg1) {
|
export function SetChaseNewGrids(arg1) {
|
||||||
return window['go']['main']['App']['SetChaseNewGrids'](arg1);
|
return window['go']['main']['App']['SetChaseNewGrids'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2202,6 +2354,10 @@ export function SetKenwoodNR(arg1) {
|
|||||||
return window['go']['main']['App']['SetKenwoodNR'](arg1);
|
return window['go']['main']['App']['SetKenwoodNR'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetKenwoodPanelMode(arg1) {
|
||||||
|
return window['go']['main']['App']['SetKenwoodPanelMode'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetKenwoodPower(arg1) {
|
export function SetKenwoodPower(arg1) {
|
||||||
return window['go']['main']['App']['SetKenwoodPower'](arg1);
|
return window['go']['main']['App']['SetKenwoodPower'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2218,6 +2374,10 @@ export function SetKenwoodRIT(arg1) {
|
|||||||
return window['go']['main']['App']['SetKenwoodRIT'](arg1);
|
return window['go']['main']['App']['SetKenwoodRIT'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetKenwoodRITOffset(arg1) {
|
||||||
|
return window['go']['main']['App']['SetKenwoodRITOffset'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetKenwoodSquelch(arg1) {
|
export function SetKenwoodSquelch(arg1) {
|
||||||
return window['go']['main']['App']['SetKenwoodSquelch'](arg1);
|
return window['go']['main']['App']['SetKenwoodSquelch'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2234,6 +2394,14 @@ export function SetLinkedAmps(arg1) {
|
|||||||
return window['go']['main']['App']['SetLinkedAmps'](arg1);
|
return window['go']['main']['App']['SetLinkedAmps'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetLoTWDownloadAllCalls(arg1) {
|
||||||
|
return window['go']['main']['App']['SetLoTWDownloadAllCalls'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetLoTWQSLDetail(arg1) {
|
||||||
|
return window['go']['main']['App']['SetLoTWQSLDetail'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetMotorFollow(arg1, arg2, arg3) {
|
export function SetMotorFollow(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['SetMotorFollow'](arg1, arg2, arg3);
|
return window['go']['main']['App']['SetMotorFollow'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
@@ -2242,6 +2410,10 @@ export function SetOpsLogQSLReceived(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SetOpsLogQSLReceived'](arg1, arg2);
|
return window['go']['main']['App']['SetOpsLogQSLReceived'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetPSKTarget(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['SetPSKTarget'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetPSUOutput(arg1) {
|
export function SetPSUOutput(arg1) {
|
||||||
return window['go']['main']['App']['SetPSUOutput'](arg1);
|
return window['go']['main']['App']['SetPSUOutput'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2250,6 +2422,10 @@ export function SetPassphrase(arg1) {
|
|||||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetScpClublogEnabled(arg1) {
|
||||||
|
return window['go']['main']['App']['SetScpClublogEnabled'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetScpEnabled(arg1) {
|
export function SetScpEnabled(arg1) {
|
||||||
return window['go']['main']['App']['SetScpEnabled'](arg1);
|
return window['go']['main']['App']['SetScpEnabled'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2262,8 +2438,80 @@ export function SetSpotTTLMinutes(arg1) {
|
|||||||
return window['go']['main']['App']['SetSpotTTLMinutes'](arg1);
|
return window['go']['main']['App']['SetSpotTTLMinutes'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetTCIRecordAudio(arg1) {
|
export function SetTCIAGC(arg1) {
|
||||||
return window['go']['main']['App']['SetTCIRecordAudio'](arg1);
|
return window['go']['main']['App']['SetTCIAGC'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCIANF(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTCIANF'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCIAPF(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTCIAPF'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCIDrive(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTCIDrive'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCIFilter(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['SetTCIFilter'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCILock(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTCILock'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCIMicLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTCIMicLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCIMute(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTCIMute'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCINB(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTCINB'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCINR(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTCINR'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCIRIT(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTCIRIT'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCIRITOffset(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTCIRITOffset'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCISquelch(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTCISquelch'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCISquelchLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTCISquelchLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCITune(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTCITune'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCITuneDrive(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTCITuneDrive'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCIVolume(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTCIVolume'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCIXIT(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTCIXIT'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetTCIXITOffset(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTCIXITOffset'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetTelemetryEnabled(arg1) {
|
export function SetTelemetryEnabled(arg1) {
|
||||||
@@ -2278,6 +2526,14 @@ export function SetUltrabeamDirection(arg1) {
|
|||||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetWatchlistContestCalls(arg1) {
|
||||||
|
return window['go']['main']['App']['SetWatchlistContestCalls'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetWatchlistContestPattern(arg1) {
|
||||||
|
return window['go']['main']['App']['SetWatchlistContestPattern'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetWinkeyerTrace(arg1) {
|
export function SetWinkeyerTrace(arg1) {
|
||||||
return window['go']['main']['App']['SetWinkeyerTrace'](arg1);
|
return window['go']['main']['App']['SetWinkeyerTrace'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2286,6 +2542,18 @@ export function SetWorkedCallVariants(arg1) {
|
|||||||
return window['go']['main']['App']['SetWorkedCallVariants'](arg1);
|
return window['go']['main']['App']['SetWorkedCallVariants'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetWsjtFollowMode(arg1) {
|
||||||
|
return window['go']['main']['App']['SetWsjtFollowMode'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetWsjtHighlight(arg1) {
|
||||||
|
return window['go']['main']['App']['SetWsjtHighlight'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetWsjtHighlightWorked(arg1) {
|
||||||
|
return window['go']['main']['App']['SetWsjtHighlightWorked'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetYaesuAFGain(arg1) {
|
export function SetYaesuAFGain(arg1) {
|
||||||
return window['go']['main']['App']['SetYaesuAFGain'](arg1);
|
return window['go']['main']['App']['SetYaesuAFGain'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2370,10 +2638,6 @@ export function StartCWDecoder() {
|
|||||||
return window['go']['main']['App']['StartCWDecoder']();
|
return window['go']['main']['App']['StartCWDecoder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StartTCIAudio(arg1, arg2) {
|
|
||||||
return window['go']['main']['App']['StartTCIAudio'](arg1, arg2);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StationSetRelay(arg1, arg2, arg3) {
|
export function StationSetRelay(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
@@ -2382,10 +2646,6 @@ export function StopCWDecoder() {
|
|||||||
return window['go']['main']['App']['StopCWDecoder']();
|
return window['go']['main']['App']['StopCWDecoder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StopTCIAudio() {
|
|
||||||
return window['go']['main']['App']['StopTCIAudio']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SwitchCATRig(arg1) {
|
export function SwitchCATRig(arg1) {
|
||||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2398,10 +2658,26 @@ export function SyncPOTAHunterLog(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TCISendCW(arg1) {
|
||||||
|
return window['go']['main']['App']['TCISendCW'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TCISetKeySpeed(arg1) {
|
||||||
|
return window['go']['main']['App']['TCISetKeySpeed'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TCIStopCW() {
|
||||||
|
return window['go']['main']['App']['TCIStopCW']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TailLogFile(arg1) {
|
export function TailLogFile(arg1) {
|
||||||
return window['go']['main']['App']['TailLogFile'](arg1);
|
return window['go']['main']['App']['TailLogFile'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TakeAutoCallTarget(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['TakeAutoCallTarget'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function TestCloudlogUpload() {
|
export function TestCloudlogUpload() {
|
||||||
return window['go']['main']['App']['TestCloudlogUpload']();
|
return window['go']['main']['App']['TestCloudlogUpload']();
|
||||||
}
|
}
|
||||||
@@ -2422,6 +2698,10 @@ export function TestHRDLogUpload() {
|
|||||||
return window['go']['main']['App']['TestHRDLogUpload']();
|
return window['go']['main']['App']['TestHRDLogUpload']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestHamQTHUpload() {
|
||||||
|
return window['go']['main']['App']['TestHamQTHUpload']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TestLoTWUpload() {
|
export function TestLoTWUpload() {
|
||||||
return window['go']['main']['App']['TestLoTWUpload']();
|
return window['go']['main']['App']['TestLoTWUpload']();
|
||||||
}
|
}
|
||||||
@@ -2530,10 +2810,38 @@ export function UploadCallsign(arg1) {
|
|||||||
return window['go']['main']['App']['UploadCallsign'](arg1);
|
return window['go']['main']['App']['UploadCallsign'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function UploadFullLogHamQTH() {
|
||||||
|
return window['go']['main']['App']['UploadFullLogHamQTH']();
|
||||||
|
}
|
||||||
|
|
||||||
export function UploadQSOsManual(arg1, arg2) {
|
export function UploadQSOsManual(arg1, arg2) {
|
||||||
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function WatchlistAdd(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['WatchlistAdd'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WatchlistEntries() {
|
||||||
|
return window['go']['main']['App']['WatchlistEntries']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WatchlistRemove(arg1) {
|
||||||
|
return window['go']['main']['App']['WatchlistRemove'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WatchlistSetContest(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['WatchlistSetContest'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WatchlistSetNotify(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['WatchlistSetNotify'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WatchlistWorkedSlots(arg1) {
|
||||||
|
return window['go']['main']['App']['WatchlistWorkedSlots'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function WebPublishColumns() {
|
export function WebPublishColumns() {
|
||||||
return window['go']['main']['App']['WebPublishColumns']();
|
return window['go']['main']['App']['WebPublishColumns']();
|
||||||
}
|
}
|
||||||
|
|||||||
+614
-14
@@ -983,6 +983,7 @@ export namespace cat {
|
|||||||
mode?: string;
|
mode?: string;
|
||||||
transmitting: boolean;
|
transmitting: boolean;
|
||||||
split: boolean;
|
split: boolean;
|
||||||
|
sub_hz: number;
|
||||||
s_meter: number;
|
s_meter: number;
|
||||||
power_meter: number;
|
power_meter: number;
|
||||||
swr_meter: number;
|
swr_meter: number;
|
||||||
@@ -1006,6 +1007,7 @@ export namespace cat {
|
|||||||
att: number;
|
att: number;
|
||||||
filter: number;
|
filter: number;
|
||||||
antenna: number;
|
antenna: number;
|
||||||
|
atu_on: boolean;
|
||||||
pbt_inner: number;
|
pbt_inner: number;
|
||||||
pbt_outer: number;
|
pbt_outer: number;
|
||||||
manual_notch: boolean;
|
manual_notch: boolean;
|
||||||
@@ -1030,6 +1032,7 @@ export namespace cat {
|
|||||||
this.mode = source["mode"];
|
this.mode = source["mode"];
|
||||||
this.transmitting = source["transmitting"];
|
this.transmitting = source["transmitting"];
|
||||||
this.split = source["split"];
|
this.split = source["split"];
|
||||||
|
this.sub_hz = source["sub_hz"];
|
||||||
this.s_meter = source["s_meter"];
|
this.s_meter = source["s_meter"];
|
||||||
this.power_meter = source["power_meter"];
|
this.power_meter = source["power_meter"];
|
||||||
this.swr_meter = source["swr_meter"];
|
this.swr_meter = source["swr_meter"];
|
||||||
@@ -1053,6 +1056,7 @@ export namespace cat {
|
|||||||
this.att = source["att"];
|
this.att = source["att"];
|
||||||
this.filter = source["filter"];
|
this.filter = source["filter"];
|
||||||
this.antenna = source["antenna"];
|
this.antenna = source["antenna"];
|
||||||
|
this.atu_on = source["atu_on"];
|
||||||
this.pbt_inner = source["pbt_inner"];
|
this.pbt_inner = source["pbt_inner"];
|
||||||
this.pbt_outer = source["pbt_outer"];
|
this.pbt_outer = source["pbt_outer"];
|
||||||
this.manual_notch = source["manual_notch"];
|
this.manual_notch = source["manual_notch"];
|
||||||
@@ -1072,12 +1076,14 @@ export namespace cat {
|
|||||||
model?: string;
|
model?: string;
|
||||||
elecraft: boolean;
|
elecraft: boolean;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
|
data_sub?: string;
|
||||||
transmitting: boolean;
|
transmitting: boolean;
|
||||||
split: boolean;
|
split: boolean;
|
||||||
split_tx_hz: number;
|
split_tx_hz: number;
|
||||||
s_meter: number;
|
s_meter: number;
|
||||||
s_meter_raw: number;
|
s_meter_raw: number;
|
||||||
power_meter: number;
|
power_meter: number;
|
||||||
|
power_w: number;
|
||||||
swr: number;
|
swr: number;
|
||||||
swr_raw: number;
|
swr_raw: number;
|
||||||
rf_power: number;
|
rf_power: number;
|
||||||
@@ -1094,6 +1100,7 @@ export namespace cat {
|
|||||||
antenna: number;
|
antenna: number;
|
||||||
rit: boolean;
|
rit: boolean;
|
||||||
xit: boolean;
|
xit: boolean;
|
||||||
|
rit_offset: number;
|
||||||
key_speed: number;
|
key_speed: number;
|
||||||
meters_provisional: boolean;
|
meters_provisional: boolean;
|
||||||
|
|
||||||
@@ -1107,12 +1114,14 @@ export namespace cat {
|
|||||||
this.model = source["model"];
|
this.model = source["model"];
|
||||||
this.elecraft = source["elecraft"];
|
this.elecraft = source["elecraft"];
|
||||||
this.mode = source["mode"];
|
this.mode = source["mode"];
|
||||||
|
this.data_sub = source["data_sub"];
|
||||||
this.transmitting = source["transmitting"];
|
this.transmitting = source["transmitting"];
|
||||||
this.split = source["split"];
|
this.split = source["split"];
|
||||||
this.split_tx_hz = source["split_tx_hz"];
|
this.split_tx_hz = source["split_tx_hz"];
|
||||||
this.s_meter = source["s_meter"];
|
this.s_meter = source["s_meter"];
|
||||||
this.s_meter_raw = source["s_meter_raw"];
|
this.s_meter_raw = source["s_meter_raw"];
|
||||||
this.power_meter = source["power_meter"];
|
this.power_meter = source["power_meter"];
|
||||||
|
this.power_w = source["power_w"];
|
||||||
this.swr = source["swr"];
|
this.swr = source["swr"];
|
||||||
this.swr_raw = source["swr_raw"];
|
this.swr_raw = source["swr_raw"];
|
||||||
this.rf_power = source["rf_power"];
|
this.rf_power = source["rf_power"];
|
||||||
@@ -1129,6 +1138,7 @@ export namespace cat {
|
|||||||
this.antenna = source["antenna"];
|
this.antenna = source["antenna"];
|
||||||
this.rit = source["rit"];
|
this.rit = source["rit"];
|
||||||
this.xit = source["xit"];
|
this.xit = source["xit"];
|
||||||
|
this.rit_offset = source["rit_offset"];
|
||||||
this.key_speed = source["key_speed"];
|
this.key_speed = source["key_speed"];
|
||||||
this.meters_provisional = source["meters_provisional"];
|
this.meters_provisional = source["meters_provisional"];
|
||||||
}
|
}
|
||||||
@@ -1194,6 +1204,7 @@ export namespace cat {
|
|||||||
low_hz: number;
|
low_hz: number;
|
||||||
high_hz: number;
|
high_hz: number;
|
||||||
fixed: boolean;
|
fixed: boolean;
|
||||||
|
unsupported: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ScopeSweep(source);
|
return new ScopeSweep(source);
|
||||||
@@ -1206,28 +1217,77 @@ export namespace cat {
|
|||||||
this.low_hz = source["low_hz"];
|
this.low_hz = source["low_hz"];
|
||||||
this.high_hz = source["high_hz"];
|
this.high_hz = source["high_hz"];
|
||||||
this.fixed = source["fixed"];
|
this.fixed = source["fixed"];
|
||||||
|
this.unsupported = source["unsupported"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class TCIAudioStatus {
|
export class TCIPanelState {
|
||||||
running: boolean;
|
connected: boolean;
|
||||||
sample_rate: number;
|
device?: string;
|
||||||
frames: number;
|
protocol?: string;
|
||||||
samples: number;
|
drive: number;
|
||||||
peak_db: number;
|
tune_drive: number;
|
||||||
last_err?: string;
|
mic_level: number;
|
||||||
|
tx_enabled: boolean;
|
||||||
|
tx: boolean;
|
||||||
|
tuning: boolean;
|
||||||
|
volume: number;
|
||||||
|
mute: boolean;
|
||||||
|
agc?: string;
|
||||||
|
squelch_on: boolean;
|
||||||
|
squelch: number;
|
||||||
|
nb: boolean;
|
||||||
|
nr: boolean;
|
||||||
|
anf: boolean;
|
||||||
|
apf: boolean;
|
||||||
|
filter_lo: number;
|
||||||
|
filter_hi: number;
|
||||||
|
rit: boolean;
|
||||||
|
rit_offset: number;
|
||||||
|
xit: boolean;
|
||||||
|
xit_offset: number;
|
||||||
|
lock: boolean;
|
||||||
|
split: boolean;
|
||||||
|
smeter: number;
|
||||||
|
tx_power_w: number;
|
||||||
|
tx_swr: number;
|
||||||
|
modulations?: string[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new TCIAudioStatus(source);
|
return new TCIPanelState(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.running = source["running"];
|
this.connected = source["connected"];
|
||||||
this.sample_rate = source["sample_rate"];
|
this.device = source["device"];
|
||||||
this.frames = source["frames"];
|
this.protocol = source["protocol"];
|
||||||
this.samples = source["samples"];
|
this.drive = source["drive"];
|
||||||
this.peak_db = source["peak_db"];
|
this.tune_drive = source["tune_drive"];
|
||||||
this.last_err = source["last_err"];
|
this.mic_level = source["mic_level"];
|
||||||
|
this.tx_enabled = source["tx_enabled"];
|
||||||
|
this.tx = source["tx"];
|
||||||
|
this.tuning = source["tuning"];
|
||||||
|
this.volume = source["volume"];
|
||||||
|
this.mute = source["mute"];
|
||||||
|
this.agc = source["agc"];
|
||||||
|
this.squelch_on = source["squelch_on"];
|
||||||
|
this.squelch = source["squelch"];
|
||||||
|
this.nb = source["nb"];
|
||||||
|
this.nr = source["nr"];
|
||||||
|
this.anf = source["anf"];
|
||||||
|
this.apf = source["apf"];
|
||||||
|
this.filter_lo = source["filter_lo"];
|
||||||
|
this.filter_hi = source["filter_hi"];
|
||||||
|
this.rit = source["rit"];
|
||||||
|
this.rit_offset = source["rit_offset"];
|
||||||
|
this.xit = source["xit"];
|
||||||
|
this.xit_offset = source["xit_offset"];
|
||||||
|
this.lock = source["lock"];
|
||||||
|
this.split = source["split"];
|
||||||
|
this.smeter = source["smeter"];
|
||||||
|
this.tx_power_w = source["tx_power_w"];
|
||||||
|
this.tx_swr = source["tx_swr"];
|
||||||
|
this.modulations = source["modulations"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class YaesuTXState {
|
export class YaesuTXState {
|
||||||
@@ -1406,6 +1466,37 @@ export namespace contest {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace dxped {
|
||||||
|
|
||||||
|
export class News {
|
||||||
|
title: string;
|
||||||
|
link: string;
|
||||||
|
pub_date: string;
|
||||||
|
excerpt: string;
|
||||||
|
creator: string;
|
||||||
|
image_url: string;
|
||||||
|
tag: string;
|
||||||
|
calls: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new News(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.title = source["title"];
|
||||||
|
this.link = source["link"];
|
||||||
|
this.pub_date = source["pub_date"];
|
||||||
|
this.excerpt = source["excerpt"];
|
||||||
|
this.creator = source["creator"];
|
||||||
|
this.image_url = source["image_url"];
|
||||||
|
this.tag = source["tag"];
|
||||||
|
this.calls = source["calls"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace extsvc {
|
export namespace extsvc {
|
||||||
|
|
||||||
export class ServiceConfig {
|
export class ServiceConfig {
|
||||||
@@ -1462,6 +1553,7 @@ export namespace extsvc {
|
|||||||
eqsl: ServiceConfig;
|
eqsl: ServiceConfig;
|
||||||
cloudlog: ServiceConfig;
|
cloudlog: ServiceConfig;
|
||||||
hamlog: ServiceConfig;
|
hamlog: ServiceConfig;
|
||||||
|
hamqth: ServiceConfig;
|
||||||
delete_remote: boolean;
|
delete_remote: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@@ -1477,6 +1569,7 @@ export namespace extsvc {
|
|||||||
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
||||||
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
|
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
|
||||||
this.hamlog = this.convertValues(source["hamlog"], ServiceConfig);
|
this.hamlog = this.convertValues(source["hamlog"], ServiceConfig);
|
||||||
|
this.hamqth = this.convertValues(source["hamqth"], ServiceConfig);
|
||||||
this.delete_remote = source["delete_remote"];
|
this.delete_remote = source["delete_remote"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1520,6 +1613,51 @@ export namespace extsvc {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace kpa {
|
||||||
|
|
||||||
|
export class Status {
|
||||||
|
connected: boolean;
|
||||||
|
transport: string;
|
||||||
|
model?: string;
|
||||||
|
last_error?: string;
|
||||||
|
power_on: boolean;
|
||||||
|
operate: boolean;
|
||||||
|
fwd_w: number;
|
||||||
|
swr: number;
|
||||||
|
volt_v: number;
|
||||||
|
cur_a: number;
|
||||||
|
temp_c: number;
|
||||||
|
band?: string;
|
||||||
|
tuning: boolean;
|
||||||
|
fault_code: number;
|
||||||
|
fault_text?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Status(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.connected = source["connected"];
|
||||||
|
this.transport = source["transport"];
|
||||||
|
this.model = source["model"];
|
||||||
|
this.last_error = source["last_error"];
|
||||||
|
this.power_on = source["power_on"];
|
||||||
|
this.operate = source["operate"];
|
||||||
|
this.fwd_w = source["fwd_w"];
|
||||||
|
this.swr = source["swr"];
|
||||||
|
this.volt_v = source["volt_v"];
|
||||||
|
this.cur_a = source["cur_a"];
|
||||||
|
this.temp_c = source["temp_c"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.tuning = source["tuning"];
|
||||||
|
this.fault_code = source["fault_code"];
|
||||||
|
this.fault_text = source["fault_text"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace lookup {
|
export namespace lookup {
|
||||||
|
|
||||||
export class Result {
|
export class Result {
|
||||||
@@ -1721,6 +1859,7 @@ export namespace main {
|
|||||||
pgxl?: powergenius.Status;
|
pgxl?: powergenius.Status;
|
||||||
spe?: spe.Status;
|
spe?: spe.Status;
|
||||||
acom?: acom.Status;
|
acom?: acom.Status;
|
||||||
|
kpa?: kpa.Status;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new AmpStatus(source);
|
return new AmpStatus(source);
|
||||||
@@ -1734,6 +1873,7 @@ export namespace main {
|
|||||||
this.pgxl = this.convertValues(source["pgxl"], powergenius.Status);
|
this.pgxl = this.convertValues(source["pgxl"], powergenius.Status);
|
||||||
this.spe = this.convertValues(source["spe"], spe.Status);
|
this.spe = this.convertValues(source["spe"], spe.Status);
|
||||||
this.acom = this.convertValues(source["acom"], acom.Status);
|
this.acom = this.convertValues(source["acom"], acom.Status);
|
||||||
|
this.kpa = this.convertValues(source["kpa"], kpa.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
@@ -1758,6 +1898,8 @@ export namespace main {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
use_for_my_antenna: boolean;
|
||||||
|
ant1_port: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new AntGeniusSettings(source);
|
return new AntGeniusSettings(source);
|
||||||
@@ -1768,6 +1910,8 @@ export namespace main {
|
|||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
this.host = source["host"];
|
this.host = source["host"];
|
||||||
this.password = source["password"];
|
this.password = source["password"];
|
||||||
|
this.use_for_my_antenna = source["use_for_my_antenna"];
|
||||||
|
this.ant1_port = source["ant1_port"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class AudioSettings {
|
export class AudioSettings {
|
||||||
@@ -1780,6 +1924,7 @@ export namespace main {
|
|||||||
preroll_seconds: number;
|
preroll_seconds: number;
|
||||||
ptt_method: string;
|
ptt_method: string;
|
||||||
ptt_port: string;
|
ptt_port: string;
|
||||||
|
ptt_data: boolean;
|
||||||
format: string;
|
format: string;
|
||||||
from_gain: number;
|
from_gain: number;
|
||||||
mic_gain: number;
|
mic_gain: number;
|
||||||
@@ -1801,6 +1946,7 @@ export namespace main {
|
|||||||
this.preroll_seconds = source["preroll_seconds"];
|
this.preroll_seconds = source["preroll_seconds"];
|
||||||
this.ptt_method = source["ptt_method"];
|
this.ptt_method = source["ptt_method"];
|
||||||
this.ptt_port = source["ptt_port"];
|
this.ptt_port = source["ptt_port"];
|
||||||
|
this.ptt_data = source["ptt_data"];
|
||||||
this.format = source["format"];
|
this.format = source["format"];
|
||||||
this.from_gain = source["from_gain"];
|
this.from_gain = source["from_gain"];
|
||||||
this.mic_gain = source["mic_gain"];
|
this.mic_gain = source["mic_gain"];
|
||||||
@@ -1808,6 +1954,66 @@ export namespace main {
|
|||||||
this.qso_play_gain = source["qso_play_gain"];
|
this.qso_play_gain = source["qso_play_gain"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class AutoCallSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
only: string;
|
||||||
|
attempts: number;
|
||||||
|
watched_attempts: number;
|
||||||
|
misses: number;
|
||||||
|
max_rounds: number;
|
||||||
|
rest_min: number;
|
||||||
|
on_screen_only: boolean;
|
||||||
|
trace: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AutoCallSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.only = source["only"];
|
||||||
|
this.attempts = source["attempts"];
|
||||||
|
this.watched_attempts = source["watched_attempts"];
|
||||||
|
this.misses = source["misses"];
|
||||||
|
this.max_rounds = source["max_rounds"];
|
||||||
|
this.rest_min = source["rest_min"];
|
||||||
|
this.on_screen_only = source["on_screen_only"];
|
||||||
|
this.trace = source["trace"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class AutoCallStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
only: string;
|
||||||
|
target: string;
|
||||||
|
waiting: string;
|
||||||
|
calls: number;
|
||||||
|
max: number;
|
||||||
|
misses: number;
|
||||||
|
max_miss: number;
|
||||||
|
stopped: boolean;
|
||||||
|
greylisted: number;
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AutoCallStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.only = source["only"];
|
||||||
|
this.target = source["target"];
|
||||||
|
this.waiting = source["waiting"];
|
||||||
|
this.calls = source["calls"];
|
||||||
|
this.max = source["max"];
|
||||||
|
this.misses = source["misses"];
|
||||||
|
this.max_miss = source["max_miss"];
|
||||||
|
this.stopped = source["stopped"];
|
||||||
|
this.greylisted = source["greylisted"];
|
||||||
|
this.reason = source["reason"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class AutostartLaunchResult {
|
export class AutostartLaunchResult {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -2154,6 +2360,7 @@ export namespace main {
|
|||||||
backend: string;
|
backend: string;
|
||||||
omnirig_rig: number;
|
omnirig_rig: number;
|
||||||
omnirig_vfo: string;
|
omnirig_vfo: string;
|
||||||
|
digi_as_usb: boolean;
|
||||||
flex_host: string;
|
flex_host: string;
|
||||||
flex_port: number;
|
flex_port: number;
|
||||||
flex_spots: boolean;
|
flex_spots: boolean;
|
||||||
@@ -2168,6 +2375,7 @@ export namespace main {
|
|||||||
yaesu_baud: number;
|
yaesu_baud: number;
|
||||||
kenwood_host: string;
|
kenwood_host: string;
|
||||||
kenwood_port: string;
|
kenwood_port: string;
|
||||||
|
kenwood_link: string;
|
||||||
kenwood_baud: number;
|
kenwood_baud: number;
|
||||||
kenwood_data_mode: string;
|
kenwood_data_mode: string;
|
||||||
yaesu_low_lines: boolean;
|
yaesu_low_lines: boolean;
|
||||||
@@ -2205,6 +2413,7 @@ export namespace main {
|
|||||||
this.backend = source["backend"];
|
this.backend = source["backend"];
|
||||||
this.omnirig_rig = source["omnirig_rig"];
|
this.omnirig_rig = source["omnirig_rig"];
|
||||||
this.omnirig_vfo = source["omnirig_vfo"];
|
this.omnirig_vfo = source["omnirig_vfo"];
|
||||||
|
this.digi_as_usb = source["digi_as_usb"];
|
||||||
this.flex_host = source["flex_host"];
|
this.flex_host = source["flex_host"];
|
||||||
this.flex_port = source["flex_port"];
|
this.flex_port = source["flex_port"];
|
||||||
this.flex_spots = source["flex_spots"];
|
this.flex_spots = source["flex_spots"];
|
||||||
@@ -2219,6 +2428,7 @@ export namespace main {
|
|||||||
this.yaesu_baud = source["yaesu_baud"];
|
this.yaesu_baud = source["yaesu_baud"];
|
||||||
this.kenwood_host = source["kenwood_host"];
|
this.kenwood_host = source["kenwood_host"];
|
||||||
this.kenwood_port = source["kenwood_port"];
|
this.kenwood_port = source["kenwood_port"];
|
||||||
|
this.kenwood_link = source["kenwood_link"];
|
||||||
this.kenwood_baud = source["kenwood_baud"];
|
this.kenwood_baud = source["kenwood_baud"];
|
||||||
this.kenwood_data_mode = source["kenwood_data_mode"];
|
this.kenwood_data_mode = source["kenwood_data_mode"];
|
||||||
this.yaesu_low_lines = source["yaesu_low_lines"];
|
this.yaesu_low_lines = source["yaesu_low_lines"];
|
||||||
@@ -2317,6 +2527,20 @@ export namespace main {
|
|||||||
this.at = source["at"];
|
this.at = source["at"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class ChaseSettings {
|
||||||
|
mode: string;
|
||||||
|
sources: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ChaseSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.sources = source["sources"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ChatMessage {
|
export class ChatMessage {
|
||||||
id: number;
|
id: number;
|
||||||
operator: string;
|
operator: string;
|
||||||
@@ -2545,6 +2769,44 @@ export namespace main {
|
|||||||
this.rec_slot = source["rec_slot"];
|
this.rec_slot = source["rec_slot"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class DXpedition {
|
||||||
|
dxcc: string;
|
||||||
|
callsign: string;
|
||||||
|
calls: string[];
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
bands: string[];
|
||||||
|
modes: string[];
|
||||||
|
qsl: string;
|
||||||
|
operators: string;
|
||||||
|
source: string;
|
||||||
|
link: string;
|
||||||
|
status: string;
|
||||||
|
status_chase: string;
|
||||||
|
unconfirmed: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new DXpedition(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.dxcc = source["dxcc"];
|
||||||
|
this.callsign = source["callsign"];
|
||||||
|
this.calls = source["calls"];
|
||||||
|
this.start_date = source["start_date"];
|
||||||
|
this.end_date = source["end_date"];
|
||||||
|
this.bands = source["bands"];
|
||||||
|
this.modes = source["modes"];
|
||||||
|
this.qsl = source["qsl"];
|
||||||
|
this.operators = source["operators"];
|
||||||
|
this.source = source["source"];
|
||||||
|
this.link = source["link"];
|
||||||
|
this.status = source["status"];
|
||||||
|
this.status_chase = source["status_chase"];
|
||||||
|
this.unconfirmed = source["unconfirmed"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class DatabaseSettings {
|
export class DatabaseSettings {
|
||||||
path: string;
|
path: string;
|
||||||
default_path: string;
|
default_path: string;
|
||||||
@@ -2853,6 +3115,7 @@ export namespace main {
|
|||||||
rst_phone: string[];
|
rst_phone: string[];
|
||||||
rst_cw: string[];
|
rst_cw: string[];
|
||||||
rst_digital: string[];
|
rst_digital: string[];
|
||||||
|
satellites: string[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ListsSettings(source);
|
return new ListsSettings(source);
|
||||||
@@ -2865,6 +3128,7 @@ export namespace main {
|
|||||||
this.rst_phone = source["rst_phone"];
|
this.rst_phone = source["rst_phone"];
|
||||||
this.rst_cw = source["rst_cw"];
|
this.rst_cw = source["rst_cw"];
|
||||||
this.rst_digital = source["rst_digital"];
|
this.rst_digital = source["rst_digital"];
|
||||||
|
this.satellites = source["satellites"];
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
@@ -2961,6 +3225,8 @@ export namespace main {
|
|||||||
entity_worked: string;
|
entity_worked: string;
|
||||||
not_worked: string;
|
not_worked: string;
|
||||||
current_entry: string;
|
current_entry: string;
|
||||||
|
mark_worked: string;
|
||||||
|
mark_confirmed: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new MatrixColors(source);
|
return new MatrixColors(source);
|
||||||
@@ -2975,6 +3241,8 @@ export namespace main {
|
|||||||
this.entity_worked = source["entity_worked"];
|
this.entity_worked = source["entity_worked"];
|
||||||
this.not_worked = source["not_worked"];
|
this.not_worked = source["not_worked"];
|
||||||
this.current_entry = source["current_entry"];
|
this.current_entry = source["current_entry"];
|
||||||
|
this.mark_worked = source["mark_worked"];
|
||||||
|
this.mark_confirmed = source["mark_confirmed"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3109,6 +3377,20 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PSKTargetSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
scope: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new PSKTargetSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.scope = source["scope"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class PSUSettings {
|
export class PSUSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
com_port: string;
|
com_port: string;
|
||||||
@@ -3161,11 +3443,13 @@ export namespace main {
|
|||||||
eqsl_sent: string;
|
eqsl_sent: string;
|
||||||
eqsl_rcvd: string;
|
eqsl_rcvd: string;
|
||||||
clublog_status: string;
|
clublog_status: string;
|
||||||
|
clublog_confirmed: string;
|
||||||
hrdlog_status: string;
|
hrdlog_status: string;
|
||||||
qrzcom_status: string;
|
qrzcom_status: string;
|
||||||
qrzcom_confirmed: string;
|
qrzcom_confirmed: string;
|
||||||
hamlog_status: string;
|
hamlog_status: string;
|
||||||
hamlog_confirmed: string;
|
hamlog_confirmed: string;
|
||||||
|
hamqth_status: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new QSLDefaults(source);
|
return new QSLDefaults(source);
|
||||||
@@ -3180,11 +3464,13 @@ export namespace main {
|
|||||||
this.eqsl_sent = source["eqsl_sent"];
|
this.eqsl_sent = source["eqsl_sent"];
|
||||||
this.eqsl_rcvd = source["eqsl_rcvd"];
|
this.eqsl_rcvd = source["eqsl_rcvd"];
|
||||||
this.clublog_status = source["clublog_status"];
|
this.clublog_status = source["clublog_status"];
|
||||||
|
this.clublog_confirmed = source["clublog_confirmed"];
|
||||||
this.hrdlog_status = source["hrdlog_status"];
|
this.hrdlog_status = source["hrdlog_status"];
|
||||||
this.qrzcom_status = source["qrzcom_status"];
|
this.qrzcom_status = source["qrzcom_status"];
|
||||||
this.qrzcom_confirmed = source["qrzcom_confirmed"];
|
this.qrzcom_confirmed = source["qrzcom_confirmed"];
|
||||||
this.hamlog_status = source["hamlog_status"];
|
this.hamlog_status = source["hamlog_status"];
|
||||||
this.hamlog_confirmed = source["hamlog_confirmed"];
|
this.hamlog_confirmed = source["hamlog_confirmed"];
|
||||||
|
this.hamqth_status = source["hamqth_status"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class QSLEmailTemplates {
|
export class QSLEmailTemplates {
|
||||||
@@ -3361,6 +3647,36 @@ export namespace main {
|
|||||||
this.team_last60 = source["team_last60"];
|
this.team_last60 = source["team_last60"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class RDAApplyResult {
|
||||||
|
applied: number;
|
||||||
|
failed: number;
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RDAApplyResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.applied = source["applied"];
|
||||||
|
this.failed = source["failed"];
|
||||||
|
this.message = source["message"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class RDAChoice {
|
||||||
|
qso_id: number;
|
||||||
|
district: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RDAChoice(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.qso_id = source["qso_id"];
|
||||||
|
this.district = source["district"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class RDAConflict {
|
export class RDAConflict {
|
||||||
qso_id: number;
|
qso_id: number;
|
||||||
callsign: string;
|
callsign: string;
|
||||||
@@ -3434,6 +3750,62 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class RadioConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
settings: CATSettings;
|
||||||
|
my_rig: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RadioConfig(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.settings = this.convertValues(source["settings"], CATSettings);
|
||||||
|
this.my_rig = source["my_rig"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class RadioListEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
backend: string;
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RadioListEntry(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.label = source["label"];
|
||||||
|
this.backend = source["backend"];
|
||||||
|
this.active = source["active"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class RelayAutoRule {
|
export class RelayAutoRule {
|
||||||
device_id: string;
|
device_id: string;
|
||||||
relay: number;
|
relay: number;
|
||||||
@@ -3633,6 +4005,7 @@ export namespace main {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
count: number;
|
count: number;
|
||||||
updated?: string;
|
updated?: string;
|
||||||
|
clublog: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ScpStatus(source);
|
return new ScpStatus(source);
|
||||||
@@ -3643,6 +4016,7 @@ export namespace main {
|
|||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
this.count = source["count"];
|
this.count = source["count"];
|
||||||
this.updated = source["updated"];
|
this.updated = source["updated"];
|
||||||
|
this.clublog = source["clublog"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class SecretStatus {
|
export class SecretStatus {
|
||||||
@@ -3752,6 +4126,11 @@ export namespace main {
|
|||||||
new_county: boolean;
|
new_county: boolean;
|
||||||
county?: string;
|
county?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
|
new_state: boolean;
|
||||||
|
unconf_status?: boolean;
|
||||||
|
unconf_pfx?: boolean;
|
||||||
|
unconf_cty?: boolean;
|
||||||
|
unconf_state?: boolean;
|
||||||
new_pota: boolean;
|
new_pota: boolean;
|
||||||
grid?: string;
|
grid?: string;
|
||||||
new_grid: boolean;
|
new_grid: boolean;
|
||||||
@@ -3778,6 +4157,11 @@ export namespace main {
|
|||||||
this.new_county = source["new_county"];
|
this.new_county = source["new_county"];
|
||||||
this.county = source["county"];
|
this.county = source["county"];
|
||||||
this.state = source["state"];
|
this.state = source["state"];
|
||||||
|
this.new_state = source["new_state"];
|
||||||
|
this.unconf_status = source["unconf_status"];
|
||||||
|
this.unconf_pfx = source["unconf_pfx"];
|
||||||
|
this.unconf_cty = source["unconf_cty"];
|
||||||
|
this.unconf_state = source["unconf_state"];
|
||||||
this.new_pota = source["new_pota"];
|
this.new_pota = source["new_pota"];
|
||||||
this.grid = source["grid"];
|
this.grid = source["grid"];
|
||||||
this.new_grid = source["new_grid"];
|
this.new_grid = source["new_grid"];
|
||||||
@@ -4102,6 +4486,24 @@ export namespace main {
|
|||||||
this.text = source["text"];
|
this.text = source["text"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class WatchlistSlotQuery {
|
||||||
|
call: string;
|
||||||
|
band: string;
|
||||||
|
mode: string;
|
||||||
|
contest: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new WatchlistSlotQuery(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.call = source["call"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.contest = source["contest"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class WebPublishStatus {
|
export class WebPublishStatus {
|
||||||
last_run: string;
|
last_run: string;
|
||||||
last_err: string;
|
last_err: string;
|
||||||
@@ -4568,6 +4970,8 @@ export namespace pskr {
|
|||||||
last_err?: string;
|
last_err?: string;
|
||||||
broker: string;
|
broker: string;
|
||||||
bands: string[];
|
bands: string[];
|
||||||
|
near_km: number;
|
||||||
|
squares: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Status(source);
|
return new Status(source);
|
||||||
@@ -4581,6 +4985,8 @@ export namespace pskr {
|
|||||||
this.last_err = source["last_err"];
|
this.last_err = source["last_err"];
|
||||||
this.broker = source["broker"];
|
this.broker = source["broker"];
|
||||||
this.bands = source["bands"];
|
this.bands = source["bands"];
|
||||||
|
this.near_km = source["near_km"];
|
||||||
|
this.squares = source["squares"];
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
@@ -4604,6 +5010,132 @@ export namespace pskr {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace pskrtgt {
|
||||||
|
|
||||||
|
export class Bin {
|
||||||
|
offset_hz: number;
|
||||||
|
count: number;
|
||||||
|
avg_snr: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Bin(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.offset_hz = source["offset_hz"];
|
||||||
|
this.count = source["count"];
|
||||||
|
this.avg_snr = source["avg_snr"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Entry {
|
||||||
|
call: string;
|
||||||
|
grid: string;
|
||||||
|
snr: number;
|
||||||
|
offset_hz: number;
|
||||||
|
age_sec: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Entry(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.call = source["call"];
|
||||||
|
this.grid = source["grid"];
|
||||||
|
this.snr = source["snr"];
|
||||||
|
this.offset_hz = source["offset_hz"];
|
||||||
|
this.age_sec = source["age_sec"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Analysis {
|
||||||
|
target: string;
|
||||||
|
mode?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
online: boolean;
|
||||||
|
spots: number;
|
||||||
|
he_me: boolean;
|
||||||
|
he_me_seconds: number;
|
||||||
|
he_me_snr: number;
|
||||||
|
he_me_offset_hz: number;
|
||||||
|
target_uploads: boolean;
|
||||||
|
target_grid?: string;
|
||||||
|
near_him_count: number;
|
||||||
|
near_him_top: Entry[];
|
||||||
|
from_my_area_count: number;
|
||||||
|
from_my_area_top: Entry[];
|
||||||
|
path_open: boolean;
|
||||||
|
heard_by_count: number;
|
||||||
|
heard_near_me: number;
|
||||||
|
heard_near_me_top: Entry[];
|
||||||
|
decoded_by_count: number;
|
||||||
|
decoded_by_top: Entry[];
|
||||||
|
decoded_by_calls: string[];
|
||||||
|
pileup_count: number;
|
||||||
|
dial_hz: number;
|
||||||
|
ceiling_hz: number;
|
||||||
|
decodes_in_window: number;
|
||||||
|
bins: Bin[];
|
||||||
|
suggested_offset: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Analysis(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.target = source["target"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.online = source["online"];
|
||||||
|
this.spots = source["spots"];
|
||||||
|
this.he_me = source["he_me"];
|
||||||
|
this.he_me_seconds = source["he_me_seconds"];
|
||||||
|
this.he_me_snr = source["he_me_snr"];
|
||||||
|
this.he_me_offset_hz = source["he_me_offset_hz"];
|
||||||
|
this.target_uploads = source["target_uploads"];
|
||||||
|
this.target_grid = source["target_grid"];
|
||||||
|
this.near_him_count = source["near_him_count"];
|
||||||
|
this.near_him_top = this.convertValues(source["near_him_top"], Entry);
|
||||||
|
this.from_my_area_count = source["from_my_area_count"];
|
||||||
|
this.from_my_area_top = this.convertValues(source["from_my_area_top"], Entry);
|
||||||
|
this.path_open = source["path_open"];
|
||||||
|
this.heard_by_count = source["heard_by_count"];
|
||||||
|
this.heard_near_me = source["heard_near_me"];
|
||||||
|
this.heard_near_me_top = this.convertValues(source["heard_near_me_top"], Entry);
|
||||||
|
this.decoded_by_count = source["decoded_by_count"];
|
||||||
|
this.decoded_by_top = this.convertValues(source["decoded_by_top"], Entry);
|
||||||
|
this.decoded_by_calls = source["decoded_by_calls"];
|
||||||
|
this.pileup_count = source["pileup_count"];
|
||||||
|
this.dial_hz = source["dial_hz"];
|
||||||
|
this.ceiling_hz = source["ceiling_hz"];
|
||||||
|
this.decodes_in_window = source["decodes_in_window"];
|
||||||
|
this.bins = this.convertValues(source["bins"], Bin);
|
||||||
|
this.suggested_offset = source["suggested_offset"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace psu {
|
export namespace psu {
|
||||||
|
|
||||||
export class Status {
|
export class Status {
|
||||||
@@ -4836,6 +5368,7 @@ export namespace qso {
|
|||||||
band: string;
|
band: string;
|
||||||
class: string;
|
class: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
call?: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new BandStatus(source);
|
return new BandStatus(source);
|
||||||
@@ -4846,6 +5379,7 @@ export namespace qso {
|
|||||||
this.band = source["band"];
|
this.band = source["band"];
|
||||||
this.class = source["class"];
|
this.class = source["class"];
|
||||||
this.status = source["status"];
|
this.status = source["status"];
|
||||||
|
this.call = source["call"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class Bucket {
|
export class Bucket {
|
||||||
@@ -5020,6 +5554,8 @@ export namespace qso {
|
|||||||
qrzcom_qso_upload_status?: string;
|
qrzcom_qso_upload_status?: string;
|
||||||
qrzcom_qso_download_date?: string;
|
qrzcom_qso_download_date?: string;
|
||||||
qrzcom_qso_download_status?: string;
|
qrzcom_qso_download_status?: string;
|
||||||
|
clublog_qso_download_date?: string;
|
||||||
|
clublog_qso_download_status?: string;
|
||||||
contest_id?: string;
|
contest_id?: string;
|
||||||
srx?: number;
|
srx?: number;
|
||||||
stx?: number;
|
stx?: number;
|
||||||
@@ -5162,6 +5698,8 @@ export namespace qso {
|
|||||||
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
|
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
|
||||||
this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"];
|
this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"];
|
||||||
this.qrzcom_qso_download_status = source["qrzcom_qso_download_status"];
|
this.qrzcom_qso_download_status = source["qrzcom_qso_download_status"];
|
||||||
|
this.clublog_qso_download_date = source["clublog_qso_download_date"];
|
||||||
|
this.clublog_qso_download_status = source["clublog_qso_download_status"];
|
||||||
this.contest_id = source["contest_id"];
|
this.contest_id = source["contest_id"];
|
||||||
this.srx = source["srx"];
|
this.srx = source["srx"];
|
||||||
this.stx = source["stx"];
|
this.stx = source["stx"];
|
||||||
@@ -5789,6 +6327,68 @@ export namespace udp {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace watchlist {
|
||||||
|
|
||||||
|
export class Entry {
|
||||||
|
callsign: string;
|
||||||
|
// Go type: time
|
||||||
|
lastSeen: any;
|
||||||
|
lastSeenStr: string;
|
||||||
|
// Go type: time
|
||||||
|
addedAt: any;
|
||||||
|
spotCount: number;
|
||||||
|
isContest: boolean;
|
||||||
|
notify: boolean;
|
||||||
|
isExpedition: boolean;
|
||||||
|
clubLogQSOs24h: number;
|
||||||
|
clubLogTotalQSOs: number;
|
||||||
|
clubLogHasOQRS: boolean;
|
||||||
|
clubLogLiveStream: boolean;
|
||||||
|
// Go type: time
|
||||||
|
clubLogUpdatedAt?: any;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Entry(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.callsign = source["callsign"];
|
||||||
|
this.lastSeen = this.convertValues(source["lastSeen"], null);
|
||||||
|
this.lastSeenStr = source["lastSeenStr"];
|
||||||
|
this.addedAt = this.convertValues(source["addedAt"], null);
|
||||||
|
this.spotCount = source["spotCount"];
|
||||||
|
this.isContest = source["isContest"];
|
||||||
|
this.notify = source["notify"];
|
||||||
|
this.isExpedition = source["isExpedition"];
|
||||||
|
this.clubLogQSOs24h = source["clubLogQSOs24h"];
|
||||||
|
this.clubLogTotalQSOs = source["clubLogTotalQSOs"];
|
||||||
|
this.clubLogHasOQRS = source["clubLogHasOQRS"];
|
||||||
|
this.clubLogLiveStream = source["clubLogLiveStream"];
|
||||||
|
this.clubLogUpdatedAt = this.convertValues(source["clubLogUpdatedAt"], null);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace webpub {
|
export namespace webpub {
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
|
|||||||
@@ -267,6 +267,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
|
|||||||
w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
||||||
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
||||||
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
||||||
|
w("CLUBLOG_QSO_DOWNLOAD_DATE", q.ClublogDownloadDate)
|
||||||
|
w("CLUBLOG_QSO_DOWNLOAD_STATUS", q.ClublogDownloadStatus)
|
||||||
|
|
||||||
// --- Contest ---
|
// --- Contest ---
|
||||||
w("CONTEST_ID", q.ContestID)
|
w("CONTEST_ID", q.ContestID)
|
||||||
@@ -441,5 +443,11 @@ func adifCounty(state, county string) string {
|
|||||||
if c == "" || s == "" || strings.Contains(c, ",") {
|
if c == "" || s == "" || strings.Contains(c, ",") {
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
// The "STATE,County" join is the ADIF secondary-subdivision format, and that
|
||||||
|
// enumeration is a US thing — a two-letter state code. Prefixing a Canadian
|
||||||
|
// "ONTARIO" produced "ONTARIO,Kawartha", which is valid nowhere.
|
||||||
|
if len(s) != 2 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
return strings.ToUpper(s) + "," + c
|
return strings.ToUpper(s) + "," + c
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ var Fields = []FieldDef{
|
|||||||
{Name: "QRZCOM_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
{Name: "QRZCOM_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||||
{Name: "QRZCOM_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
{Name: "QRZCOM_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||||
{Name: "QRZCOM_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
{Name: "QRZCOM_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||||
|
// App-defined pair (no standard ADIF field): Club Log's log-match download.
|
||||||
|
{Name: "CLUBLOG_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "CLUBLOG_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||||
{Name: "HAMLOGEU_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
{Name: "HAMLOGEU_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
||||||
{Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
|
{Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
|
||||||
{Name: "HAMQTH_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
{Name: "HAMQTH_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ var adifPromoted = stringSet(
|
|||||||
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
|
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
|
||||||
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
|
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
|
||||||
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
|
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
|
||||||
|
"clublog_qso_download_date", "clublog_qso_download_status",
|
||||||
// Contest
|
// Contest
|
||||||
"contest_id", "srx", "stx", "srx_string", "stx_string",
|
"contest_id", "srx", "stx", "srx_string", "stx_string",
|
||||||
"check", "precedence", "arrl_sect",
|
"check", "precedence", "arrl_sect",
|
||||||
@@ -493,6 +494,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
|||||||
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
|
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
|
||||||
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
|
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
|
||||||
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
|
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
|
||||||
|
q.ClublogDownloadDate = rec["clublog_qso_download_date"]
|
||||||
|
q.ClublogDownloadStatus = rec["clublog_qso_download_status"]
|
||||||
|
|
||||||
// Contest
|
// Contest
|
||||||
q.ContestID = rec["contest_id"]
|
q.ContestID = rec["contest_id"]
|
||||||
|
|||||||
+18
-26
@@ -264,9 +264,18 @@ func matchWildcard(pattern, v string) bool {
|
|||||||
return re.MatchString(v)
|
return re.MatchString(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InferMode guesses a spot's mode from its comment and frequency. Cluster spots
|
// InferMode works out a spot's mode from its comment, then from the band plan.
|
||||||
// don't carry a mode field, so we read common tags (FT8/FT4/CW/RTTY/…) then fall
|
//
|
||||||
// back to the digital watering holes and the band-plan CW/phone split.
|
// Cluster lines carry no mode field, so this is all there is — and it feeds the
|
||||||
|
// alert rules, the FlexRadio panadapter colours and the spot the radio is told
|
||||||
|
// about. It used to end with a bare "return SSB" for anything its handful of CW
|
||||||
|
// ranges did not cover, which is how a 30 m FT8 spot at 10131.5 raised an alert
|
||||||
|
// announcing SSB while the cluster list beside it said DATA.
|
||||||
|
//
|
||||||
|
// The comment still wins when it names a mode: a spotter who wrote FT8 knows
|
||||||
|
// better than any table. Below that is the shared band plan (bandplan.go), and
|
||||||
|
// below THAT is nothing — an empty mode, which callers read as "unknown" rather
|
||||||
|
// than as a claim.
|
||||||
func InferMode(comment string, freqHz int64) string {
|
func InferMode(comment string, freqHz int64) string {
|
||||||
c := strings.ToUpper(comment)
|
c := strings.ToUpper(comment)
|
||||||
switch {
|
switch {
|
||||||
@@ -280,30 +289,13 @@ func InferMode(comment string, freqHz int64) string {
|
|||||||
return "PSK"
|
return "PSK"
|
||||||
case strings.Contains(c, "JS8"):
|
case strings.Contains(c, "JS8"):
|
||||||
return "JS8"
|
return "JS8"
|
||||||
case strings.Contains(c, "CW"):
|
// WPM is as good as the word CW: nothing else is reported in words per
|
||||||
|
// minute, and RBN spots carry it on every line.
|
||||||
|
case strings.Contains(c, "CW"), strings.Contains(c, "WPM"):
|
||||||
return "CW"
|
return "CW"
|
||||||
case strings.Contains(c, "SSB") || strings.Contains(c, "USB") || strings.Contains(c, "LSB") || strings.Contains(c, "PH"):
|
case strings.Contains(c, "SSB"), strings.Contains(c, "USB"),
|
||||||
|
strings.Contains(c, "LSB"), strings.Contains(c, "PH"):
|
||||||
return "SSB"
|
return "SSB"
|
||||||
}
|
}
|
||||||
khz := float64(freqHz) / 1000
|
return modeFromFrequency(freqHz)
|
||||||
// FT8 watering holes (…074) and FT4 (…080/…140) as a fallback.
|
|
||||||
for _, f := range []float64{1840, 3573, 7074, 10136, 14074, 18100, 21074, 24915, 28074, 50313} {
|
|
||||||
if khz >= f-1 && khz <= f+3 {
|
|
||||||
return "FT8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Band-plan CW segments (bottom of each band).
|
|
||||||
switch {
|
|
||||||
case khz >= 1810 && khz <= 1840,
|
|
||||||
khz >= 3500 && khz <= 3570,
|
|
||||||
khz >= 7000 && khz <= 7040,
|
|
||||||
khz >= 10100 && khz <= 10130,
|
|
||||||
khz >= 14000 && khz <= 14070,
|
|
||||||
khz >= 18068 && khz <= 18095,
|
|
||||||
khz >= 21000 && khz <= 21070,
|
|
||||||
khz >= 24890 && khz <= 24910,
|
|
||||||
khz >= 28000 && khz <= 28070:
|
|
||||||
return "CW"
|
|
||||||
}
|
|
||||||
return "SSB"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
// The band plan behind InferMode, and why it is written out segment by segment.
|
||||||
|
//
|
||||||
|
// A cluster line carries no mode, so it has to be inferred — and OpsLog was
|
||||||
|
// inferring it TWICE, in two places, from two different tables. The frontend
|
||||||
|
// knew that 10.130-10.150 is the 30 m data segment; this side only knew a CW
|
||||||
|
// range that stopped at 10.130 and called everything after it SSB. So a spot at
|
||||||
|
// 10131.5 showed as DATA in the cluster list and raised an alert saying SSB.
|
||||||
|
// Reported with a screenshot of exactly that, on RI1FJL.
|
||||||
|
//
|
||||||
|
// The two tables are now the same table, transcribed from the frontend's
|
||||||
|
// (frontend/src/lib/spot.ts) with its segment boundaries kept intact. They stay
|
||||||
|
// two files because they are two languages, and the test next door is what
|
||||||
|
// keeps them one answer.
|
||||||
|
|
||||||
|
// modeSeg is one stretch of a band, in Hz, and what is worked there.
|
||||||
|
type modeSeg struct {
|
||||||
|
loHz, hiHz int64
|
||||||
|
mode string
|
||||||
|
}
|
||||||
|
|
||||||
|
// bandPlan is scanned IN ORDER, so a narrow watering hole listed before the
|
||||||
|
// wide segment it sits inside wins — FT8 at 14.074 before the 14.070-14.100
|
||||||
|
// data block. Order is the whole mechanism here; sorting this table by
|
||||||
|
// frequency would quietly turn every FT8 hole into "DATA".
|
||||||
|
var bandPlan = []modeSeg{
|
||||||
|
{1_800_000, 1_838_000, "CW"}, {1_838_000, 1_840_000, "FT8"}, {1_840_000, 2_000_000, "SSB"},
|
||||||
|
|
||||||
|
{3_573_000, 3_575_000, "FT8"}, {3_575_000, 3_578_000, "FT4"}, {3_500_000, 3_580_000, "CW"},
|
||||||
|
{3_580_000, 3_600_000, "DATA"}, {3_600_000, 4_000_000, "SSB"},
|
||||||
|
|
||||||
|
{5_357_000, 5_360_000, "FT8"}, {5_300_000, 5_500_000, "SSB"},
|
||||||
|
|
||||||
|
{7_074_000, 7_077_000, "FT8"}, {7_047_500, 7_048_500, "FT4"},
|
||||||
|
{7_000_000, 7_040_000, "CW"}, {7_040_000, 7_100_000, "DATA"}, {7_100_000, 7_300_000, "SSB"},
|
||||||
|
|
||||||
|
// 30 m: CW to 10.130, data above it — and nothing else. No SSB on this band.
|
||||||
|
// The FT8/FT4 watering holes come first, or a 10.136 spot with a bare
|
||||||
|
// comment reads as generic DATA — which is what every skimmerless spot of a
|
||||||
|
// DXpedition's 30 m FT8 slot did.
|
||||||
|
{10_136_000, 10_139_000, "FT8"}, {10_140_000, 10_143_000, "FT4"},
|
||||||
|
{10_100_000, 10_130_000, "CW"}, {10_130_000, 10_150_000, "DATA"},
|
||||||
|
|
||||||
|
{14_074_000, 14_077_000, "FT8"}, {14_080_000, 14_081_500, "FT4"},
|
||||||
|
{14_000_000, 14_070_000, "CW"}, {14_070_000, 14_100_000, "DATA"}, {14_100_000, 14_350_000, "SSB"},
|
||||||
|
|
||||||
|
{18_100_000, 18_103_000, "FT8"}, {18_104_000, 18_107_000, "FT4"},
|
||||||
|
{18_068_000, 18_095_000, "CW"}, {18_095_000, 18_110_000, "DATA"}, {18_110_000, 18_168_000, "SSB"},
|
||||||
|
|
||||||
|
{21_074_000, 21_077_000, "FT8"}, {21_140_000, 21_143_000, "FT4"},
|
||||||
|
{21_000_000, 21_070_000, "CW"}, {21_070_000, 21_150_000, "DATA"}, {21_150_000, 21_450_000, "SSB"},
|
||||||
|
|
||||||
|
{24_915_000, 24_918_000, "FT8"}, {24_919_000, 24_922_000, "FT4"},
|
||||||
|
{24_890_000, 24_915_000, "CW"}, {24_915_000, 24_940_000, "DATA"}, {24_940_000, 24_990_000, "SSB"},
|
||||||
|
|
||||||
|
{28_074_000, 28_077_000, "FT8"}, {28_180_000, 28_183_000, "FT4"},
|
||||||
|
{28_000_000, 28_070_000, "CW"}, {28_070_000, 28_300_000, "DATA"}, {28_300_000, 29_700_000, "SSB"},
|
||||||
|
|
||||||
|
{50_313_000, 50_316_000, "FT8"}, {50_318_000, 50_321_000, "FT4"},
|
||||||
|
{50_000_000, 50_100_000, "CW"}, {50_100_000, 50_500_000, "SSB"},
|
||||||
|
|
||||||
|
{144_174_000, 144_177_000, "FT8"},
|
||||||
|
{144_000_000, 144_150_000, "CW"}, {144_150_000, 144_500_000, "SSB"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeFromFrequency returns what the band plan says is worked at freqHz, or ""
|
||||||
|
// where it says nothing.
|
||||||
|
//
|
||||||
|
// Empty rather than a guess: a spot outside every listed segment is on a band
|
||||||
|
// this table does not cover, and answering "SSB" for it is how a 30 m data spot
|
||||||
|
// came to raise an SSB alert.
|
||||||
|
func modeFromFrequency(freqHz int64) string {
|
||||||
|
for _, s := range bandPlan {
|
||||||
|
if freqHz >= s.loHz && freqHz < s.hiHz {
|
||||||
|
return s.mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The spot that started it: RI1FJL on 10131.5, an FT8 station in the 30 m data
|
||||||
|
// segment. The cluster list showed DATA and the alert announced SSB, because
|
||||||
|
// this side's table stopped its CW range at 10.130 and called everything above
|
||||||
|
// it phone.
|
||||||
|
func TestInferModeDoesNotCallThirtyMetresSSB(t *testing.T) {
|
||||||
|
if got := InferMode("", 10_131_500); got != "DATA" {
|
||||||
|
t.Errorf("10131.5 kHz → %q, want DATA (there is no SSB on 30 m)", got)
|
||||||
|
}
|
||||||
|
// And the CW half of the same band still reads as CW.
|
||||||
|
if got := InferMode("", 10_110_000); got != "CW" {
|
||||||
|
t.Errorf("10110 kHz → %q, want CW", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The comment beats the band plan. A spotter who names the mode knows something
|
||||||
|
// no table does — an FT8 station calling outside the usual watering hole, a CW
|
||||||
|
// operation in a data segment during a contest.
|
||||||
|
func TestInferModeTrustsTheCommentFirst(t *testing.T) {
|
||||||
|
cases := map[string]struct {
|
||||||
|
comment string
|
||||||
|
freqHz int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
"FT8 said outright": {"-14 dB FT8", 14_200_000, "FT8"},
|
||||||
|
"CW in a data segment": {"CW 599", 14_080_000, "CW"},
|
||||||
|
"RBN reports WPM not CW": {"18 dB 25 WPM CQ", 14_080_000, "CW"},
|
||||||
|
"phone below the split": {"SSB net", 7_010_000, "SSB"},
|
||||||
|
}
|
||||||
|
for name, c := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if got := InferMode(c.comment, c.freqHz); got != c.want {
|
||||||
|
t.Errorf("InferMode(%q, %d) = %q, want %q", c.comment, c.freqHz, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The watering holes are listed before the wide data blocks they sit inside, and
|
||||||
|
// the table is scanned in order — so sorting it by frequency would quietly turn
|
||||||
|
// every FT8 hole into "DATA". This is what would fail if somebody did.
|
||||||
|
func TestBandPlanKeepsTheWateringHolesAheadOfTheDataBlocks(t *testing.T) {
|
||||||
|
holes := map[int64]string{
|
||||||
|
3_573_500: "FT8",
|
||||||
|
7_074_500: "FT8",
|
||||||
|
7_048_000: "FT4",
|
||||||
|
14_074_500: "FT8",
|
||||||
|
14_080_500: "FT4",
|
||||||
|
21_074_500: "FT8",
|
||||||
|
28_074_500: "FT8",
|
||||||
|
50_313_500: "FT8",
|
||||||
|
}
|
||||||
|
for hz, want := range holes {
|
||||||
|
if got := modeFromFrequency(hz); got != want {
|
||||||
|
t.Errorf("%d Hz → %q, want %q", hz, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing is claimed for a frequency the plan does not cover. Answering "SSB"
|
||||||
|
// for whatever fell through is the exact fault this file exists to fix.
|
||||||
|
func TestModeFromFrequencyIsSilentWhenItDoesNotKnow(t *testing.T) {
|
||||||
|
for _, hz := range []int64{500_000, 6_000_000, 70_200_000, 432_100_000} {
|
||||||
|
if got := modeFromFrequency(hz); got != "" {
|
||||||
|
t.Errorf("%d Hz → %q, want \"\" (outside the plan)", hz, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,7 +157,21 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error {
|
|||||||
// instantly, the PTT is released 120 ms later, and NOTHING says why —
|
// instantly, the PTT is released 120 ms later, and NOTHING says why —
|
||||||
// which is exactly what a station heard as "it plays once, then never
|
// which is exactly what a station heard as "it plays once, then never
|
||||||
// again": the call succeeded, the sound did not.
|
// again": the call succeeded, the sound did not.
|
||||||
if err := playPCM(deviceID, pcm, rate, ch, bits, stop); err != nil {
|
play := func() error { return playPCM(deviceID, pcm, rate, ch, bits, stop) }
|
||||||
|
if deviceID == NetworkDeviceID {
|
||||||
|
// Straight to the radio over its own link. Decided HERE rather than
|
||||||
|
// inside playPCM because there is no Windows endpoint to open: asked
|
||||||
|
// for one, the system complains about a missing device instead of
|
||||||
|
// saying the true thing, which is that no radio is connected.
|
||||||
|
fn := networkPlayer()
|
||||||
|
play = func() error {
|
||||||
|
if fn == nil {
|
||||||
|
return errNoNetworkRadio
|
||||||
|
}
|
||||||
|
return fn(pcm, rate, ch, bits, stop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := play(); err != nil {
|
||||||
LogSink("audio: playback on %q failed: %v", DeviceName(deviceID), err)
|
LogSink("audio: playback on %q failed: %v", DeviceName(deviceID), err)
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -342,6 +356,29 @@ func (m *Manager) StartTXAudio(micDev, toRadioDev string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StartTXAudioNetwork pipes the live microphone into a SEND function instead
|
||||||
|
// of a render device — the talk button when the radio is reached over its own
|
||||||
|
// link. No ring and no pacing goroutine: the microphone delivers in real time,
|
||||||
|
// and the sender re-frames to the rig's cadence, so the capture callback IS
|
||||||
|
// the clock.
|
||||||
|
func (m *Manager) StartTXAudioNetwork(micDev string, send func([]byte) error) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.txStop != nil {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return fmt.Errorf("TX audio already running")
|
||||||
|
}
|
||||||
|
stop := make(chan struct{})
|
||||||
|
m.txStop = stop
|
||||||
|
m.mu.Unlock()
|
||||||
|
go func() {
|
||||||
|
if err := captureStream(micDev, stop, func(chunk []byte) { _ = send(chunk) }); err != nil {
|
||||||
|
LogSink("audio: network TX capture from %q failed: %v", DeviceName(micDev), err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
m.notify()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// StopTXAudio stops the TX mic→rig passthrough.
|
// StopTXAudio stops the TX mic→rig passthrough.
|
||||||
func (m *Manager) StopTXAudio() {
|
func (m *Manager) StopTXAudio() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
// Playing a message through the RADIO instead of a sound card.
|
||||||
|
//
|
||||||
|
// A SunSDR takes its transmit audio over TCI, on the same socket as the
|
||||||
|
// commands, so the voice keyer can hand it the message directly: no virtual
|
||||||
|
// cable, no second sound card, no Windows mixer between the recording and the
|
||||||
|
// air. To everything above, that radio is simply another output device.
|
||||||
|
//
|
||||||
|
// The device it presents itself as is a name rather than a WASAPI endpoint id,
|
||||||
|
// which is why Play checks for it before opening anything: there is no endpoint
|
||||||
|
// to open, and asking Windows for one produces a confusing error about a device
|
||||||
|
// that does not exist rather than the truth, which is that nothing is connected
|
||||||
|
// to the radio.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NetworkDeviceID is the id the radio-over-network output carries in the
|
||||||
|
// settings and in the device lists. A fixed string, not a Windows endpoint id:
|
||||||
|
// it is chosen by us and must survive a radio being switched off and on.
|
||||||
|
const NetworkDeviceID = "net:radio"
|
||||||
|
|
||||||
|
// NetworkPlayer sends already-decoded PCM to the radio, returning when the
|
||||||
|
// message has been played or when stop is closed.
|
||||||
|
//
|
||||||
|
// It carries the same arguments as the sound-card path so that Play can hand
|
||||||
|
// over whatever it read, and the radio can decide what converting it needs —
|
||||||
|
// the sample rate a WAV was recorded at is not the radio's business until the
|
||||||
|
// moment it has to be resampled.
|
||||||
|
type NetworkPlayer func(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error
|
||||||
|
|
||||||
|
var (
|
||||||
|
netMu sync.RWMutex
|
||||||
|
netPlayer NetworkPlayer
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetNetworkPlayer installs (or clears, with nil) the radio's transmit path.
|
||||||
|
//
|
||||||
|
// Package-level rather than per-Manager: there is one radio, the CAT backend
|
||||||
|
// owns it, and a Manager that happened to be built before the radio connected
|
||||||
|
// would otherwise be permanently unable to reach it.
|
||||||
|
func SetNetworkPlayer(fn NetworkPlayer) {
|
||||||
|
netMu.Lock()
|
||||||
|
netPlayer = fn
|
||||||
|
netMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// networkPlayer returns the installed player, or nil.
|
||||||
|
func networkPlayer() NetworkPlayer {
|
||||||
|
netMu.RLock()
|
||||||
|
defer netMu.RUnlock()
|
||||||
|
return netPlayer
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetworkPlayerReady says whether a radio is currently able to take transmit
|
||||||
|
// audio, so the settings panel can offer the option honestly rather than
|
||||||
|
// listing a device that would fail when used.
|
||||||
|
func NetworkPlayerReady() bool { return networkPlayer() != nil }
|
||||||
|
|
||||||
|
// errNoNetworkRadio is what a message played to a radio that is not there
|
||||||
|
// comes back with. Named, because "the device could not be opened" would send
|
||||||
|
// an operator hunting through Windows sound settings for a device that never
|
||||||
|
// existed.
|
||||||
|
var errNoNetworkRadio = errors.New("no radio is connected to take the audio — check the CAT link (a TCI radio, or a network Icom with RX audio enabled)")
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user